CodeNomad/scripts/test-session-fork-native.mjs
Pascal André 6a6367b560
chore(opencode): retire adaptations for runtimes and events OpenCode V2 no longer produces (#1034)
## Summary
This PR removes OpenCode compatibility code that nothing can reach any
more:
- Code that was already dead at the former 2.0.27 minimum, and is still
dead at the current 2.1.0 minimum (#1029).
- Handlers for events and data shapes that OpenCode V2 never produces.

Each item was checked against the upstream 2.0.27 and 2.1.0 sources
before removal.

Kept on purpose:
- Workarounds for upstream bugs or limits that are still present in
2.1.0.
- Code that reads conversations written by older runtimes: V1-migrated
tool names and metadata, `workspaceID` data, task summary titles,
`session-legacy` replay.
- Code that recognises and upgrades old CLIs and live older daemons: 1.x
replacement, beta recognition, 2.0.11 service discovery, 2.0.15 native
upgrade, `/api/status` and `/api/health` identification.

## Removed or changed
**Server**
- Negotiation: one "not modern → `canonical_api`" rule replaces the
pre-2.0.4 legacy schema recognition, and runs before #1029's
`native_contract` check.
- The transport no longer rewrites `/api/status`.
- Desktop skills no longer send the pre-2.0.4 `location` and `slash`
fields.
- The legacy `auth.json` usage fallback for daemons without
`credential.list` (pre-2.0.20) is removed, along with its UI reason and
the i18n key in 10 locales. A missing endpoint still fails safely.
- Workspace selectors are rejected up front, without a native resolution
request.
- CLI service probes try `/api/info` first. An admitted daemon no longer
costs two authenticated 404s on every check.

**UI**
- Remaining V1 `session.idle`/`session.status` branches: Missions store,
pending discovery (UI and server), `eventAffectsMessages`.
- `server.instance.disposed` and the now-unused `rehydrateInstance`.
- The V1 `worktree.ready` envelope (the handler now reads the V2 shape).
- V1 tool-state titles and the `success` status alias.
- Part `time.start` and `time.end`.
- Text stored as objects or segment arrays.
- `deriveMessageStatus`.
- The V1 `error.data.message` shape.
- `projectID` and `location` fallbacks on `session.created`.
- The replay-only `session.message.content.updated`.
- The unreachable `output.answers` fallback.

**Fixtures, Missions and docs**
- Legacy branches removed from the location, fork, families, pruning and
automation native fixtures. The WSL setup fixture now compares against
the reported version instead of 2.0.11.
- Unused 2.0.21 launch helpers removed.
- The Missions native and trajectory fixtures also run on the minimum
lane.
- The capability proof checks the reported version.
- Stale 2.0.2x labels now say "as of the 2.1.0 minimum".

**Follow-ups from the #1029 post-merge review**
- Test fixtures no longer send the ignored `recent: ""` compaction field
(18 sites).
- The manual compaction-isolation fixture accepts any published version
at or above the minimum.
- `CONTRIBUTING.md` wording updated.

114 files, +319 / −1117: source +90 / −487, tests/fixtures/CI +214 /
−604, i18n −10, docs +15 / −16.

## Validation (rebased on dev with #1029)
**Typechecks:** server and UI pass.

**Server tests**
- Touched areas: 480/480.
- Full server CI step: 2430 pass, 0 fail, 7 skipped, 1 cancelled. The
cancelled test is the `index.test.ts` real-process shutdown test; it hit
its 5 s timeout under load. It passes alone (4/4), and that file is
unchanged versus dev.

**UI unit tests**
- Touched files: 230/230.
- CI steps: 574/574, 200/200 and 519/519.

**Browser suites**, one at a time:
- Provider and service usage: provider-usage 13/13, service-usage 3/3.
- Missions: mission-visibility 3/3, mission-control-feedback 5/5,
mission-lifecycle-revision 10/10, mission-lifecycle-navigation 10/10,
mission-current-passage 1/1, mission-native-family 11/11.
- Transcript and rendering: compaction-responsiveness 5/5,
session-rendering 47/47, reasoning-collapse 3/3, render-cost 17/17,
tool-images 4/4, system-messages 10/10.
- Other: search-highlight 11 pass (1 skipped by its env gate),
panel-extensions 13/13.

**Native fixtures on an isolated 2.1.0 CLI:** location, session-fork,
automation, session-pruning, missions native, missions trajectory,
provider-usage, Missions capability proof and compaction-isolation all
pass. The WSL setup fixture was not run (it needs a WSL distro with a
Linux CLI).

## Left for later
- `RuntimeIdentity.discovery` is now write-only.
- `ownsLocation` has an unused `_client` parameter (about 30 callers).
- A test-only V1 translation in `auto-accept-manager.test.ts`.
- Out of scope: HTML-entity decoding (a product decision), the
`MutableHashMap` loop, the pruning-UI import-order workaround, unused
log-based Missions readers, and the unshipped managed-owner module with
its 2.0.24-pinned test.

Oversized source files touched: `stores/instances.ts` ~2,209,
`message-block.tsx` ~1,601, `workspaces/manager.ts` ~1,104,
`tool-call.css` ~1,047, `tool-call.tsx` ~865, `opencode-data.ts` ~865,
`api-types.ts` ~784, `session-events.ts` ~606,
`recurring-day-fixture.ts` ~535.
2026-10-20 19:19:47 +02:00

81 lines
5.3 KiB
JavaScript

import assert from "node:assert/strict"
import { spawn, execFileSync } from "node:child_process"
import { randomUUID } from "node:crypto"
import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { tsImport } from "tsx/esm/api"
import { OpenCode } from "@opencode/client"
// Explicit executable + private home/database: never discovers the user's daemon.
const cli = process.argv[2]
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute isolated CLI executable path")
const temporaryRoot = path.join(os.tmpdir(), "opencode")
await mkdir(temporaryRoot, { recursive: true })
const root = await mkdtemp(path.join(temporaryRoot, "fork-native-"))
const { forkAfterMessage } = await tsImport("../packages/ui/src/stores/session-fork.ts", import.meta.url)
const { createRuntimeFetch } = await tsImport("../packages/server/src/opencode/compatibility/transport.ts", import.meta.url)
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/i.test(key)))
for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
const password = randomUUID()
Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
OPENCODE_CONFIG_CONTENT: "{}", OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_DISABLE_FFF: "1", OPENCODE_SERVER_PASSWORD: password })
await mkdir(env.OPENCODE_CONFIG_DIR)
let output = "", spawnError
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
const stopped = new Promise(resolve => child.once("close", resolve))
child.on("error", error => { spawnError = error })
child.stdout.on("data", data => { output += data })
child.stderr.on("data", data => { output += data })
try {
const deadline = Date.now() + 30000
while (!/http:\/\/127\.0\.0\.1:\d+/.test(output)) {
if (spawnError) throw spawnError
if (Date.now() > deadline || child.exitCode !== null) throw new Error(output)
await delay(20)
}
const endpoint = { url: output.match(/http:\/\/127\.0\.0\.1:\d+/)[0], auth: { type: "basic", username: "opencode", password } }
const version = execFileSync(cli, ["--version"], { cwd: root, env, encoding: "utf8", windowsHide: true }).trim().split(/\s+/).at(-1).replace(/^v/, "")
rememberRuntime(endpoint, { version, pid: child.pid, discovery: "info" })
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint) })
const info = await client.session.create({ location: { directory: root } })
console.log(`OpenCode ${version}`)
const model = { providerID: "fixture", id: "fixture" }
const messages = [
{ id: "msg_user1", type: "user", text: "QUESTION_ONE", time: { created: 1 } },
{ id: "msg_assistant1", type: "assistant", agent: "build", model, content: [{ type: "text", text: "ANSWER_ONE" }], time: { created: 2, completed: 3 }, finish: "stop" },
{ id: "msg_hidden", type: "model-switched", model, time: { created: 4 } },
{ id: "msg_user2", type: "user", text: "QUESTION_TWO", time: { created: 5 } },
{ id: "msg_assistant2", type: "assistant", agent: "build", model, content: [{ type: "text", text: "ANSWER_TWO" }], time: { created: 6, completed: 7 }, finish: "stop" },
]
await client.session.remove({ sessionID: info.id })
const imported = await client.session.import({ info, messages })
const actual = (await client.message.list({ sessionID: imported.id, order: "asc", limit: 100 })).data
const contents = list => list.map(({ id, ...rest }) => rest)
for (const index of [0, 1, 3, 4]) {
// Force page crossings, including across a hidden native message.
const paginated = { ...client, message: { ...client.message,
list: input => client.message.list({ ...input, limit: 2 }) } }
const fork = await forkAfterMessage(paginated, imported.id, actual[index].id, () => true)
const copied = (await client.message.list({ sessionID: fork.id, order: "asc", limit: 100 })).data
assert.deepEqual(contents(copied), contents(actual.slice(0, index + 1)))
await client.session.remove({ sessionID: fork.id })
console.log(`PASS native inclusive fork: ${actual[index].type} at ${index}`)
}
// Also exercise a user prompt as the last native message.
const tail = await client.session.fork({ sessionID: imported.id, before: actual.at(-1).id })
const tailMessages = (await client.message.list({ sessionID: tail.id, order: "asc", limit: 100 })).data
const fork = await forkAfterMessage(client, tail.id, tailMessages.at(-1).id, () => true)
assert.deepEqual(contents((await client.message.list({ sessionID: fork.id, order: "asc", limit: 100 })).data), contents(tailMessages))
assert.deepEqual((await client.message.list({ sessionID: imported.id, order: "asc", limit: 100 })).data, actual)
console.log("PASS native user tail, source preserved, no provider requests")
} finally {
child.kill()
await stopped
await writeFile(path.join(root, "daemon.log"), output)
console.log(`Isolated fixture: ${root}`)
}