import { spawn, type ChildProcess } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { removeTempDir, waitForExit } from "./testing/cleanup.ts"; import { workerPort } from "./testing/worker-port.ts"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const FAKE_CLI = join(SERVER_DIR, "testing", "fake-acp-cli.ts"); const PORT = workerPort(18800, 10_000); const BASE = `http://127.0.0.1:${PORT}`; const posixOnly = describe.skipIf(process.platform === "win32"); interface Msg { id: string; role: string; kind: string; text?: string; parentId?: string | null; queued?: boolean; } function activePath(messages: Msg[], leafId: string | null): Msg[] { const byId = new Map(messages.map((m) => [m.id, m])); const path: Msg[] = []; let cur = leafId ? byId.get(leafId) : undefined; while (cur) { path.push(cur); cur = cur.parentId ? byId.get(cur.parentId) : undefined; } return path.reverse(); } posixOnly("conversation branching e2e (fake ACP fleet)", () => { let child: ChildProcess; let home: string; let stderr = ""; const api = async (method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> => { const res = await fetch(`${BASE}${path}`, { method, headers: body ? { "content-type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json() }; }; const getBot = async (id: string) => (await api("GET", "/api/bots")).body.bots.find((b: any) => b.id === id); const waitFor = async (predicate: () => Promise, what: string, ms = 25_000) => { const deadline = Date.now() + ms; while (!(await predicate())) { if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}. stderr: ${stderr.slice(-2000)}`); await new Promise((r) => setTimeout(r, 200)); } }; beforeAll(async () => { chmodSync(FAKE_CLI, 0o755); home = mkdtempSync(join(tmpdir(), "omb-branch-test-")); mkdirSync(join(home, ".openmausbot"), { recursive: true }); writeFileSync( join(home, ".openmausbot", "config.json"), JSON.stringify({ instances: { happy: { driver: "grokAgent", config: { cli: FAKE_CLI, fullAuto: true } }, // a second engine for the mid-thread model switch: same fake, its // own instance, so it starts with no session cursor for the thread second: { driver: "grokAgent", config: { cli: FAKE_CLI, fullAuto: true } }, hang: { driver: "grokAgent", environment: { FAKE_ACP_MODE: "hang" }, config: { cli: FAKE_CLI, fullAuto: true }, }, }, }), ); const env: NodeJS.ProcessEnv = { HOME: home, USERPROFILE: home, OMB_PORT: String(PORT), }; if (process.env.PATH) env.PATH = process.env.PATH; child = spawn(process.execPath, [join(SERVER_DIR, "index.ts")], { cwd: join(SERVER_DIR, ".."), env, stdio: ["ignore", "pipe", "pipe"], }); child.stderr!.on("data", (c) => (stderr += c)); const deadline = Date.now() + 20_000; for (;;) { try { const res = await fetch(`${BASE}/api/health`); if (res.ok) break; } catch { } if (Date.now() > deadline) throw new Error(`server never came up. stderr:\n${stderr}`); if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}. stderr:\n${stderr}`); await new Promise((r) => setTimeout(r, 150)); } }, 30_000); afterAll(async () => { await waitForExit(child, { signal: "SIGTERM" }); await removeTempDir(home); }); it( "forks on edit, replies on the new branch, and switches versions cleanly", async () => { const created = (await api("POST", "/api/bots")).body.bot; await api("PATCH", `/api/bots/${created.id}`, { modelSelection: { instanceId: "happy", model: "fake-model" }, }); expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "original question" })).status).toBe(202); await waitFor(async () => { const b = await getBot(created.id); return !b.busy && b.messages.some((m: Msg) => m.role === "bot" && m.kind === "text" && m.text?.includes("fake acp")); }, "the first reply"); let bot = await getBot(created.id); const original: Msg = bot.messages.find((m: Msg) => m.role === "user" && m.text === "original question"); const originalLeaf = bot.activeLeafId; const editBody = { text: "edited question", sendId: "edit-retry-0000000001" }; const firstEdit = await api("POST", `/api/bots/${created.id}/messages/${original.id}/edit`, editBody); expect(firstEdit.status).toBe(202); expect(firstEdit.body.message.sendId).toBe("edit-retry-0000000001"); const retried = await api("POST", `/api/bots/${created.id}/messages/${original.id}/edit`, editBody); expect(retried.status).toBe(202); expect(retried.body.message.id).toBe(firstEdit.body.message.id); const reused = await api("POST", `/api/bots/${created.id}/messages/${original.id}/edit`, { text: "something else", sendId: "edit-retry-0000000001" }); expect(reused.status).toBe(409); await waitFor(async () => { const b = await getBot(created.id); const edited = b.messages.find((m: Msg) => m.role === "user" && m.text === "edited question"); if (!edited || b.busy) return false; return activePath(b.messages, b.activeLeafId).some( (m) => m.role === "bot" && m.kind === "text" && m.id !== originalLeaf, ); }, "the reply on the edited branch"); bot = await getBot(created.id); const edited: Msg = bot.messages.find((m: Msg) => m.role === "user" && m.text === "edited question"); expect(edited.parentId).toBe(original.parentId); expect(bot.messages.filter((m: Msg) => m.role === "user" && m.text === "edited question")).toHaveLength(1); const path = activePath(bot.messages, bot.activeLeafId); expect(path.map((m) => m.text)).toContain("edited question"); expect(path.map((m) => m.text)).not.toContain("original question"); expect(bot.messages.map((m: Msg) => m.id)).toContain(original.id); const switched = await api("POST", `/api/bots/${created.id}/active-branch`, { messageId: original.id }); expect(switched.status).toBe(200); bot = await getBot(created.id); const backPath = activePath(bot.messages, bot.activeLeafId); expect(backPath.map((m) => m.text)).toContain("original question"); expect(backPath.map((m) => m.text)).not.toContain("edited question"); }, 40_000, ); it( "refuses to rewind a live thread, then edits cleanly once it is stopped", async () => { const created = (await api("POST", "/api/bots")).body.bot; await api("PATCH", `/api/bots/${created.id}`, { modelSelection: { instanceId: "hang", model: "fake-model" }, }); // start a turn that will never finish on its own expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "first try" })).status).toBe(202); await waitFor(async () => (await getBot(created.id)).busy === true, "the hung turn to start"); const backendBefore = (await getBot(created.id)).cloudBackend; const backendChange = await api("PATCH", `/api/bots/${created.id}`, { cloudBackend: "vps" }); expect(backendChange.status).toBe(409); expect(backendChange.body.error).toContain("stop the active turn"); expect((await getBot(created.id)).cloudBackend).toBe(backendBefore); const parallel = await api("POST", `/api/bots/${created.id}/messages`, { text: "sneaky second" }); expect(parallel.status).toBe(202); expect(parallel.body.queued).toBe(true); expect((await getBot(created.id)).busy).toBe(true); const bot0 = await getBot(created.id); const anyMsg = bot0.messages[0]; expect((await api("POST", `/api/bots/${created.id}/active-branch`, { messageId: anyMsg.id })).status).toBe(409); const first: Msg = bot0.messages.find((m: Msg) => m.role === "user" && m.text === "first try"); const midTurn = await api("POST", `/api/bots/${created.id}/messages/${first.id}/edit`, { text: "second try" }); expect(midTurn.status).toBe(409); expect((await getBot(created.id)).messages.filter((m: Msg) => m.text === "second try")).toHaveLength(0); // Stopping the turn auto-runs the queued "sneaky second" (stop-then-steer), so that drained turn must be stopped too. expect((await api("POST", `/api/bots/${created.id}/interrupt`)).status).toBe(200); await waitFor(async () => { const b = await getBot(created.id); return b.busy === true && b.messages.some((m: Msg) => m.text === "sneaky second" && !m.queued); }, "the queued message to drain into its own turn", 20_000); expect((await api("POST", `/api/bots/${created.id}/interrupt`)).status).toBe(200); await waitFor(async () => (await getBot(created.id)).busy === false, "the drained turn to settle", 20_000); expect((await api("POST", `/api/bots/${created.id}/messages/${first.id}/edit`, { text: "second try" })).status).toBe(202); await waitFor(async () => { const b = await getBot(created.id); return b.messages.some((m: Msg) => m.role === "user" && m.text === "second try"); }, "the forked message", 30_000); const bot = await getBot(created.id); const second: Msg = bot.messages.find((m: Msg) => m.role === "user" && m.text === "second try"); expect(second.parentId).toBe(first.parentId); const path = activePath(bot.messages, bot.activeLeafId); expect(path.at(-1)?.id).toBe(second.id); expect(path.map((m) => m.text)).not.toContain("first try"); expect(bot.messages.filter((m: Msg) => m.text === "first try")).toHaveLength(1); expect(bot.messages.filter((m: Msg) => m.text === "second try")).toHaveLength(1); }, 45_000, ); it( "replays the thread to a fresh engine after a mid-thread model switch", async () => { const created = (await api("POST", "/api/bots")).body.bot; await api("PATCH", `/api/bots/${created.id}`, { modelSelection: { instanceId: "happy", model: "fake-model" }, }); expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "my dog is named Biscuit" })).status).toBe(202); await waitFor(async () => { const b = await getBot(created.id); return !b.busy && b.messages.some((m: Msg) => m.role === "bot" && m.kind === "text"); }, "the first reply"); await api("PATCH", `/api/bots/${created.id}`, { modelSelection: { instanceId: "second", model: "fake-model" }, }); expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "what is my dog called?" })).status).toBe(202); await waitFor(async () => { const b = await getBot(created.id); return !b.busy && b.messages.filter((m: Msg) => m.role === "bot" && m.kind === "text").length >= 2; }, "the reply from the second engine"); await api("PATCH", `/api/bots/${created.id}`, { modelSelection: { instanceId: "happy", model: "fake-model" }, }); expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "and again?" })).status).toBe(202); await waitFor(async () => { const b = await getBot(created.id); return !b.busy && b.messages.filter((m: Msg) => m.role === "bot" && m.kind === "text").length >= 3; }, "the reply after switching back"); // the fake's reply is fixed, so the proof lives in the native protocol // tee: the prompt each engine received must carry the history it lacks const bot = await getBot(created.id); const log = readFileSync(join(home, ".openmausbot", "native", `${bot.threadId}.ndjson`), "utf8"); const prompts = log .split("\n") .filter(Boolean) .map((line) => JSON.parse(line)) .filter((e) => e.dir === "out" && e.msg?.method === "session/prompt") .map((e) => JSON.stringify(e.msg.params)); expect(prompts).toHaveLength(3); expect(prompts[0]).not.toContain("joining this conversation"); expect(prompts[1]).toMatch(/joining this conversation[\s\S]*User: my dog is named Biscuit[\s\S]*what is my dog called\?/); expect(prompts[1]).not.toContain("rewound"); expect(prompts[2]).toMatch(/joining this conversation[\s\S]*User: what is my dog called\?[\s\S]*and again\?/); }, 40_000, ); });