CodeNomad/scripts/test-session-fork-native.mjs
Pascal André 7312ac2234
feat(sessions): fork after user and assistant messages (#718)
## Summary

I added **Fork after this message** to completed agent responses and
user prompts. Both actions keep the selected message and open the new
conversation with an empty composer. The source conversation and its
draft are preserved.

I resolve the cut against native message history, including hidden
records and cursor pages. A tail fork checks the native boundary; if
another client appended in the meantime, I discard that fork rather than
open the wrong history. Pending prompts and unfinished responses do not
offer the action.

Closes #693.

## Validation

- UI typecheck passed.
- 7 fork regressions and 65 existing request-authority tests passed.
- 5 Chromium scenarios passed: prompt, historical response, latest
response, unfinished response, and narrow overflow menu. These exercise
the real SessionView, empty composer, preserved source draft, and
absence of an extra prompt.
- The isolated native fixture passed on OpenCode 2.0.10 and beta-19271,
including page crossings, a hidden model-change message, both tail
roles, and preserved source history. I used private homes/databases and
left the shared daemon untouched.
- I added the unit regression to CI; the browser regression is picked up
by the existing browser suite.

The shared UI implementation applies to Tauri and Electron. I have not
rebuilt or installed a desktop binary.

## Maintenance note

The touched existing files are already large: `session-api.ts` (~1,826
lines), `message-item.tsx` (~817), and `session/session-view.tsx`
(~693). I kept the new boundary logic in a separate module.
2026-09-19 21:50:59 +02:00

83 lines
5.6 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, contractProfile, runtimeIdentity } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
const { locationRequestOptions } = await tsImport("../packages/server/src/opencode/compatibility/location.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: "status" })
const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint) })
const location = { directory: root, ...(contractProfile(runtimeIdentity(endpoint)) === "legacy" ? { workspaceID: "wrk_fork_fixture" } : {}) }
const info = await client.session.create({ location }, locationRequestOptions(location))
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}`)
}