import { spawn, type ChildProcess } from "node:child_process"; import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; import { launchVerificationServer, runControlOmb, verificationServerEnvironment } from "../scripts/control-omb.ts"; import { waitForExit } from "./testing/cleanup.ts"; import { readHandoffs } from "./testing/handoff-rows.ts"; const primary = { instanceId: "opencodeGo", model: "fixture/recovery" }; const backup = { instanceId: "claude", model: "claude-sonnet-5" }; const transient = { code: -32603, message: "Internal error", data: { details: "Upstream connection timed out" } }; const jsonLines = (path: string): any[] => existsSync(path) ? readFileSync(path, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)) : []; // At boot OpenCode lists its models by opening one ACP session in this // folder. Those calls are catalog discovery, not the turns under test. const CATALOG_PROBE_FOLDER = ["", "providers", "opencode", "discovery"].join(sep); const turnCalls = (path: string) => jsonLines(path).filter((call) => !String(call.cwd ?? "").endsWith(CATALOG_PROBE_FOLDER)); async function withRecoveryFixture( options: { enabled?: boolean; method?: string; error?: unknown; afterOutput?: boolean; backupFails?: boolean; gated?: boolean; scripted?: boolean; codexBackup?: boolean }, check: (fixture: { api: (method: string, path: string, body?: unknown, expectedStatus?: number) => Promise; control: (...args: string[]) => Promise; calls: () => Array<{ pid: number; method: string }>; backupPrompts: () => any[]; backupReceipt: () => { argv: string[]; prompt: unknown; systemPrompt: string }; dataDir: string; failureFile: string; gateFile: string; evidence: unknown[]; }) => Promise, ) { const fixture = await launchVerificationServer({}, undefined, undefined, undefined, undefined, options.scripted ? { scripted: true } : undefined); const { dataDir, url, logPath } = fixture.info; const failureFile = join(dataDir, "startup-failure.json"); const gateFile = join(dataDir, "startup-release"); const rpcFile = join(dataDir, "primary-rpc.jsonl"); const backupFile = join(dataDir, "backup-prompts.jsonl"); const evidence: unknown[] = [{ url, options }]; let server: ChildProcess | undefined; const api = async (method: string, path: string, body?: unknown, expectedStatus?: number) => { const response = await fetch(url + path, { method, headers: { "content-type": "application/json", origin: url }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(30_000), }); const result = await response.json(); const context = `${method} ${path}: ${JSON.stringify(result)}`; if (expectedStatus === undefined) expect(response.ok, context).toBe(true); else expect(response.status, context).toBe(expectedStatus); return result; }; const control = async (...args: string[]) => { const result = await runControlOmb([...args, "--url", url]) as any; evidence.push({ command: args, result }); return result; }; try { await waitForExit(fixture.child, { signal: "SIGTERM" }); const configPath = join(dataDir, "config.json"); const config = JSON.parse(readFileSync(configPath, "utf8")); const cli = fileURLToPath(new URL("./testing/fake-acp-cli.ts", import.meta.url)); config.instances.history = { driver: "opencodeGo", displayName: "History fixture", config: { cli }, environment: { FAKE_ACP_MODELS: primary.model }, }; config.instances.opencodeGo = { driver: "opencodeGo", displayName: "Primary fixture", config: { cli }, environment: { FAKE_ACP_MODELS: primary.model, FAKE_ACP_RPC_APPEND_FILE: rpcFile, FAKE_ACP_RPC_FAILURE_FILE: failureFile, FAKE_ACP_RPC_FAILURE_METHOD: options.method ?? "initialize", ...(options.afterOutput ? { FAKE_ACP_RPC_FAILURE_AFTER_OUTPUT: "1" } : {}), ...(options.gated ? { FAKE_ACP_RPC_FAILURE_GATE: gateFile } : {}), }, }; config.instances.secondary = { ...config.instances.opencodeGo, displayName: "Second startup fixture" }; if (options.codexBackup) config.instances.codex = { driver: "codex", config: { cli: fileURLToPath(new URL("./testing/fake-codex-app-server.ts", import.meta.url)) }, }; config.instances.claude.environment = { ...config.instances.claude.environment, FAKE_CLAUDE_PROMPTS: backupFile, FAKE_CLAUDE_TOOL_CALLS: "[]", FAKE_CLAUDE_MODE: options.backupFails ? "not-logged-in" : "happy", }; config.automaticRecovery = { enabled: options.enabled !== false, backup }; writeFileSync(configPath, JSON.stringify(config)); const log = openSync(logPath, "a", 0o600); server = spawn(process.execPath, ["--experimental-strip-types", fileURLToPath(new URL("./index.ts", import.meta.url))], { cwd: fileURLToPath(new URL("..", import.meta.url)), env: verificationServerEnvironment({}, dataDir, Number(new URL(url).port)), stdio: ["ignore", log, log], }); closeSync(log); await expect.poll(async () => { try { return (await fetch(url + "/api/health", { signal: AbortSignal.timeout(1_000) })).ok; } catch { return false; } }, { timeout: 20_000 }).toBe(true); // A later start listens before Codex lists its models (startup-model-catalog.ts), and a recovery backup must be // a model the engine offers. if (options.codexBackup) await expect.poll(async () => { const { instances } = await api("GET", "/api/instances") as { instances: any[] }; return instances.find((instance) => instance.instanceId === "codex")?.models.options.some((option: any) => option.id === "gpt-fake-default"); }, { timeout: 20_000 }).toBe(true); // Chief first makes the starter its team's Chief, which answers a teammate's failed run with a Problems to look at turn // of its own on Claude: the backup engine, whose prompts these tests count. Stepped down, only the runs under test run. const { bots } = await api("GET", "/api/bots") as { bots: Array<{ id: string; chiefOfStaff?: boolean }> }; const starter = bots.find((bot) => bot.chiefOfStaff)!; await api("PATCH", `/api/bots/${starter.id}`, { chiefOfStaff: false }); // Armed once the server is up: the failure is the turns' to meet, not the // model discovery the server runs while it starts. writeFileSync(failureFile, JSON.stringify(options.error ?? transient)); await check({ api, control, calls: () => turnCalls(rpcFile), backupPrompts: () => jsonLines(backupFile), backupReceipt: () => { const { argv, prompt, systemPrompt } = JSON.parse(readFileSync(fixture.fixtureDumpPath, "utf8")); return { argv, prompt, systemPrompt }; }, dataDir, failureFile, gateFile, evidence, }); } finally { evidence.push({ calls: jsonLines(rpcFile), backupPrompts: jsonLines(backupFile) }); const evidencePath = `${logPath}.automatic-recovery.json`; writeFileSync(evidencePath, JSON.stringify(evidence, null, 2)); await waitForExit(server, { signal: "SIGTERM" }); await fixture.close(); console.info(JSON.stringify({ logPath, evidencePath, fixtureRemoved: !existsSync(dataDir) })); } } it("recovers once on the same thread with its history and permissions, leaving the bot default and sibling unchanged", async () => { await withRecoveryFixture({}, async ({ api, control, calls, backupPrompts, backupReceipt, evidence }) => { const { bot } = await control("new-bot", "--name", "Recover in place"); const threadId = bot.activeTaskId; const route = `/api/bots/${bot.id}/tasks/${threadId}`; const send = (text: string) => control("send", "--bot", bot.id, "--task", threadId, "--text", text); const wait = () => control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30"); await api("PATCH", route, { modelSelection: { ...primary, instanceId: "history" }, approvalMode: "ask" }); await send("REMEMBER_AUTOMATIC_RECOVERY_7Q"); expect((await wait()).status).toBe("settled"); await api("PATCH", route, { modelSelection: primary, updateBotDefault: true }); const { task: sibling } = await api("POST", `/api/bots/${bot.id}/tasks`, { title: "Sibling keeps primary" }); const state = async () => (await api("GET", "/api/bots")).bots.find((entry: any) => entry.id === bot.id); const before = await state(); const beforeTask = before.tasks.find((task: any) => task.threadId === threadId); const text = "RECOVER_THIS_WORK_ONCE_8R"; await send(text); expect((await wait()).status).toBe("settled"); expect(calls().filter((call) => call.method === "initialize.error")).toHaveLength(1); expect(calls().filter((call) => call.method === "session/prompt")).toHaveLength(0); expect(backupPrompts()).toHaveLength(1); const receipt = backupReceipt(); const prompt = JSON.stringify(receipt.prompt); expect(prompt).toContain("REMEMBER_AUTOMATIC_RECOVERY_7Q"); expect(prompt).toContain("hello from fake acp"); expect(prompt.split(text)).toHaveLength(2); expect(receipt.argv[receipt.argv.indexOf("--permission-mode") + 1]).toBe("default"); const messages = (await api("GET", `/api/threads/${threadId}/messages?limit=100`)).messages as any[]; expect(messages.filter((message) => message.role === "user" && message.text === text)).toHaveLength(1); expect(messages.find((message) => message.role === "user" && message.text === text).requestPending).toBe(false); expect(messages.filter((message) => message.tool?.name?.startsWith("recovery:"))).toHaveLength(1); expect(messages.findLast((message) => message.role === "bot" && message.kind === "text")).toMatchObject({ text: "hello from fake claude", turnSucceeded: true, }); const after = await state(); const task = after.tasks.find((entry: any) => entry.threadId === threadId); expect(task).toMatchObject({ modelSelection: backup, busy: false }); expect(task.approvalMode).toBe(beforeTask.approvalMode); expect(task.alwaysAllow).toEqual(beforeTask.alwaysAllow); expect(after.modelSelection).toEqual(primary); expect(after.approvalMode).toBe(before.approvalMode); expect(after.tasks.find((entry: any) => entry.threadId === sibling.threadId).modelSelection).toEqual(primary); await control("messages", "--bot", bot.id, "--task", threadId, "--limit", "20"); evidence.push({ beforeTask, task, messages, receipt }); }); }, 90_000); it("keeps a Set me up request a setup turn on the backup, and an edit of it too", async () => { await withRecoveryFixture({}, async ({ api, control, calls, backupPrompts, backupReceipt, evidence }) => { const { bot } = await control("new-bot", "--name", "Recover setup"); const threadId = bot.activeTaskId; const route = `/api/bots/${bot.id}/tasks/${threadId}`; const wait = () => control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30"); const userText = (prompt: unknown) => JSON.stringify(prompt); await api("PATCH", route, { modelSelection: primary, updateBotDefault: true }); await api("POST", `/api/bots/${bot.id}/setup-request`, { threadId }); await control("send", "--bot", bot.id, "--task", threadId, "--text", "sort my inbox every morning"); expect((await wait()).status).toBe("settled"); expect(calls().filter((call) => call.method === "initialize.error")).toHaveLength(1); expect(backupPrompts()).toHaveLength(1); const pressed = backupReceipt(); expect(pressed.systemPrompt).toContain("The user explicitly asked you to set yourself up"); expect(userText(pressed.prompt)).toContain("Set yourself up for this job: sort my inbox every morning"); const messages = async () => (await api("GET", `/api/threads/${threadId}/messages?limit=100`)).messages as any[]; const line = (await messages()).find((message) => message.role === "user"); expect(line).toMatchObject({ text: "sort my inbox every morning", setupRequest: true }); // The edit runs on the primary again, which fails again: the backup must still read it as setup. await api("PATCH", route, { modelSelection: primary }); await api("POST", `/api/bots/${bot.id}/messages/${line.id}/edit`, { text: "sort my inbox and my calendar", threadId }, 202); expect((await wait()).status).toBe("settled"); expect(calls().filter((call) => call.method === "initialize.error")).toHaveLength(2); expect(backupPrompts()).toHaveLength(2); const edited = backupReceipt(); expect(edited.systemPrompt).toContain("The user explicitly asked you to set yourself up"); expect(userText(edited.prompt)).toContain("Set yourself up for this job: sort my inbox and my calendar"); evidence.push({ pressed, edited, messages: await messages() }); }); }, 120_000); it("recovers a coordinated specialist and returns its result to the Chief exactly once", async () => { await withRecoveryFixture({ scripted: true }, async ({ api, control, calls, dataDir, evidence }) => { const { bot: chief } = await control("new-bot", "--name", "Recovery Chief", "--section", "Leadership"); const { bot: specialist } = await control("new-bot", "--name", "Recovery specialist", "--section", "Engineering"); await api("PATCH", `/api/bots/${chief.id}`, { chiefOfStaff: true, managedSections: ["Engineering"], acknowledgePeerScope: true, }); await api("PATCH", `/api/bots/${specialist.id}/tasks/${specialist.activeTaskId}`, { modelSelection: primary, updateBotDefault: true, approvalMode: "ask", }); const brief = "VERIFY_RECOVERED_SPECIALIST_2W"; const specialistReply = "SPECIALIST_BACKUP_VERIFIED_3X"; const finalReply = "Chief received the recovered specialist result"; const planPath = join(dataDir, "room-plan.json"); writeFileSync(planPath, JSON.stringify({ [chief.id]: { turns: [ { steps: [{ arguments: { bot_ids: [specialist.id], request_key: "recovery-check", message: brief } }], reply: "Assigned the recovery check" }, { expectContextIncludes: [specialistReply], reply: finalReply }, ] }, [specialist.id]: { turns: [{ expectContextIncludes: [brief], reply: specialistReply }] }, })); await control("send", "--bot", chief.id, "--task", chief.activeTaskId, "--text", "Ask Engineering to verify this task and report the result."); expect((await control("wait", "--bot", chief.id, "--task", chief.activeTaskId, "--timeout", "30")).status).toBe("settled"); const nodes = readHandoffs(dataDir); evidence.push({ nodes }); expect(nodes).toHaveLength(2); expect(nodes.every((node) => !node.groupId && node.status === "completed" && node.reported)).toBe(true); const child = nodes.find((node) => node.botId === specialist.id)!; expect(child.result).toBe(specialistReply); expect(nodes.find((node) => node.botId === chief.id)!.executions).toBe(2); const turns = jsonLines(`${planPath}.evidence.jsonl`); expect(turns.filter((turn) => turn.botId === chief.id)).toHaveLength(2); expect(turns.filter((turn) => turn.botId === specialist.id)).toHaveLength(1); expect(turns.find((turn) => turn.botId === specialist.id)).toMatchObject({ threadId: child.threadId, model: backup.model, permissionMode: "bypassPermissions", }); expect(calls().filter((call) => call.method === "initialize.error")).toHaveLength(1); expect(calls().some((call) => call.method === "session/prompt")).toBe(false); const chiefMessages = (await api("GET", `/api/threads/${chief.activeTaskId}/messages?limit=100`)).messages as any[]; expect(chiefMessages.filter((message) => message.text === finalReply)).toHaveLength(1); expect(chiefMessages.filter((message) => message.roomRequest?.id === child.id && message.roomRequest.phase === "result")).toHaveLength(1); const specialistMessages = (await api("GET", `/api/threads/${child.threadId}/messages?limit=100`)).messages as any[]; evidence.push({ chiefMessages, specialistMessages, turns }); expect(specialistMessages.filter((message) => message.text === specialistReply)).toHaveLength(1); expect(specialistMessages.filter((message) => message.roomRequest?.id === child.id && message.roomRequest.phase === "request" && message.text?.includes(brief))).toHaveLength(1); expect(specialistMessages.some((message) => message.role === "user")).toBe(false); expect(specialistMessages.filter((message) => message.tool?.name?.startsWith("recovery:"))).toHaveLength(1); const fleet = await api("GET", "/api/bots"); expect(fleet.groups).toEqual([]); const after = fleet.bots.find((bot: any) => bot.id === specialist.id); expect(after.modelSelection).toEqual(primary); expect(after.tasks.find((task: any) => task.threadId === child.threadId)).toMatchObject({ modelSelection: backup, approvalMode: "full", busy: false }); expect(after.tasks.find((task: any) => task.threadId === specialist.activeTaskId).modelSelection).toEqual(primary); expect(fleet.bots.find((bot: any) => bot.id === chief.id)).toMatchObject({ busy: false, waitingForTeammates: false }); await control("messages", "--bot", chief.id, "--task", chief.activeTaskId, "--limit", "20"); await control("messages", "--bot", specialist.id, "--task", child.threadId, "--limit", "20"); }); }, 90_000); it.each([true, false])("uses the ordered bot list only for proven pre-prompt failures and leaves defaults and siblings unchanged (Automatic recovery on: %s)", async (enabled) => { await withRecoveryFixture({ enabled }, async ({ api, control, calls, backupPrompts, evidence }) => { const { bot } = await control("new-bot", "--name", "Ordered startup fallback"); const threadId = bot.activeTaskId; await api("PATCH", `/api/bots/${bot.id}/tasks/${threadId}`, { modelSelection: primary, updateBotDefault: true }); const { task: sibling } = await api("POST", `/api/bots/${bot.id}/tasks`, { title: "Unchanged sibling" }); await api("PATCH", `/api/bots/${bot.id}`, { fallback: [{ instanceId: "secondary", model: primary.model }, backup] }); const text = "ORDERED_STARTUP_ONLY_3F"; await control("send", "--bot", bot.id, "--task", threadId, "--text", text); expect((await control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30")).status).toBe("settled"); expect(calls().filter(call => call.method === "initialize.error")).toHaveLength(2); expect(calls().filter(call => call.method === "session/prompt")).toHaveLength(0); expect(backupPrompts()).toHaveLength(1); const after = (await api("GET", "/api/bots")).bots.find((entry: any) => entry.id === bot.id); expect(after.modelSelection).toEqual(primary); expect(after.tasks.find((task: any) => task.threadId === sibling.threadId).modelSelection).toEqual(primary); expect(after.tasks.find((task: any) => task.threadId === threadId)).toMatchObject({ modelSelection: backup, busy: false }); const { messages } = await control("messages", "--bot", bot.id, "--task", threadId, "--limit", "30"); expect(messages.filter((message: any) => message.role === "user" && message.text === text)).toHaveLength(1); expect(messages.filter((message: any) => message.tool?.name?.startsWith("recovery:"))).toHaveLength(2); evidence.push({ after, messages }); }); }, 90_000); it.each([ { name: "disabled and the bot has no backups of its own", enabled: false, ownBackups: false }, { name: "prompt already accepted", method: "session/prompt" }, { name: "partial output", method: "session/prompt", afterOutput: true }, { name: "authentication failure", error: { code: -32603, message: "Authentication required: invalid API key" } }, ])("does not automatically replay when $name", async ({ name: _name, ownBackups = true, ...options }) => { await withRecoveryFixture(options, async ({ api, control, calls, backupPrompts }) => { const { bot } = await control("new-bot", "--name", "No replay"); const threadId = bot.activeTaskId; await api("PATCH", `/api/bots/${bot.id}/tasks/${threadId}`, { modelSelection: primary, updateBotDefault: true }); if (ownBackups) await api("PATCH", `/api/bots/${bot.id}`, { fallback: [{ instanceId: "secondary", model: primary.model }, backup] }); await control("send", "--bot", bot.id, "--task", threadId, "--text", "DO_NOT_REPLAY_THIS_9S"); expect((await control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30")).status) .toBe(options.afterOutput ? "settled" : "failed"); expect(backupPrompts()).toHaveLength(0); expect(calls().filter((call) => call.method.endsWith(".error"))).toHaveLength(1); const state = (await api("GET", "/api/bots")).bots.find((entry: any) => entry.id === bot.id); expect(state.tasks.find((task: any) => task.threadId === threadId)).toMatchObject({ modelSelection: primary, busy: false }); const { messages } = await control("messages", "--bot", bot.id, "--task", threadId, "--limit", "20"); expect(messages.filter((message: any) => message.role === "user" && message.text === "DO_NOT_REPLAY_THIS_9S")).toHaveLength(1); expect(messages.some((message: any) => message.tool?.ok === false)).toBe(true); expect(messages.some((message: any) => message.tool?.name?.startsWith("recovery:"))).toBe(false); }); }, 90_000); it("rejects missing or unavailable backup settings through the API without changing the saved recovery choice", async () => { await withRecoveryFixture({ enabled: false }, async ({ api, evidence }) => { const before = (await api("GET", "/api/config")).automaticRecovery; const missing = await api("PUT", "/api/config", { automaticRecovery: { enabled: true } }, 400); expect(missing.error).toMatch(/backup|automaticRecovery/i); const unavailable = await api("PUT", "/api/config", { automaticRecovery: { enabled: true, backup: { instanceId: "missing-fixture-engine", model: "missing-model" } }, }, 400); expect(unavailable.error).toMatch(/unavailable/i); expect((await api("GET", "/api/config")).automaticRecovery).toEqual(before); const allowed = { enabled: true, backup }; await api("PUT", "/api/config", { automaticRecovery: allowed }); expect((await api("GET", "/api/config")).automaticRecovery).toEqual(allowed); await api("PUT", "/api/config", { automaticRecovery: { enabled: false } }); expect((await api("GET", "/api/config")).automaticRecovery).toEqual({ enabled: false }); evidence.push({ rejectedSettings: [missing, unavailable], acceptedSettings: allowed }); }); }, 90_000); it("stops after one failed backup instead of looping between engines", async () => { await withRecoveryFixture({ backupFails: true }, async ({ api, control, calls, backupPrompts }) => { const { bot } = await control("new-bot", "--name", "One backup attempt"); const threadId = bot.activeTaskId; await api("PATCH", `/api/bots/${bot.id}/tasks/${threadId}`, { modelSelection: primary, updateBotDefault: true }); await control("send", "--bot", bot.id, "--task", threadId, "--text", "ONLY_ONE_BACKUP_4T"); expect((await control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30")).status).toBe("failed"); expect(backupPrompts()).toHaveLength(1); expect(calls().filter((call) => call.method === "initialize.error")).toHaveLength(1); const { messages } = await control("messages", "--bot", bot.id, "--task", threadId, "--limit", "20"); expect(messages.filter((message: any) => message.role === "user" && message.text === "ONLY_ONE_BACKUP_4T")).toHaveLength(1); expect(messages.map((message: any) => message.tool).filter((tool: any) => tool?.ok === false)) .toEqual([{ name: "error: Not logged in · Please run /login", ok: false, setup: true }]); }); }, 90_000); it.each(["workspace", "bot"])("uses the configured %s backup when Claude refuses a turn because it is signed out", async scope => { await withRecoveryFixture({ backupFails: true, codexBackup: true }, async ({ api, control, backupPrompts, evidence }) => { const healthy = { instanceId: "codex", model: "gpt-fake-default" }; await api("PUT", "/api/config", { automaticRecovery: { enabled: true, backup: healthy } }); const { bot } = await control("new-bot", "--name", "Signed out primary"); const threadId = bot.activeTaskId; await api("PATCH", `/api/bots/${bot.id}/tasks/${threadId}`, { modelSelection: backup, updateBotDefault: true, approvalMode: "ask", }); if (scope === "bot") await api("PATCH", `/api/bots/${bot.id}`, { fallback: [{ ...backup, model: "claude-opus-5" }, healthy], }); const { task: sibling } = await api("POST", `/api/bots/${bot.id}/tasks`, { title: "Sibling keeps Claude" }); const text = "RECOVER_SIGNED_OUT_CLAUDE_7A"; await control("send", "--bot", bot.id, "--task", threadId, "--text", text); const wait = await control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30"); await control("messages", "--bot", bot.id, "--task", threadId, "--limit", "30"); const { messages } = await api("GET", `/api/threads/${threadId}/messages?limit=100`); const after = (await api("GET", "/api/bots")).bots.find((entry: any) => entry.id === bot.id); evidence.push({ wait, messages, after }); expect(wait.status).toBe("settled"); expect(backupPrompts()).toHaveLength(1); expect(messages.filter((message: any) => message.role === "user" && message.text === text)).toHaveLength(1); expect(messages.filter((message: any) => message.tool?.name?.startsWith("recovery:"))).toHaveLength(1); expect(messages.find((message: any) => message.tool?.name?.startsWith("recovery:"))?.tool.name).toContain("is signed out"); expect(messages.some((message: any) => message.tool?.ok === false)).toBe(false); expect(messages.find((message: any) => message.role === "user" && message.text === text)?.requestPending).toBe(false); expect(messages.findLast((message: any) => message.role === "bot" && message.kind === "text")) .toMatchObject({ text: "done from fake codex", turnSucceeded: true }); expect(after.modelSelection).toEqual(backup); expect(after.tasks.find((task: any) => task.threadId === sibling.threadId).modelSelection).toEqual(backup); expect(after.tasks.find((task: any) => task.threadId === threadId)) .toMatchObject({ modelSelection: healthy, approvalMode: "ask", busy: false }); }); }, 90_000); it("preserves the sign-in error when automatic recovery is disabled", async () => { await withRecoveryFixture({ backupFails: true, codexBackup: true }, async ({ api, control }) => { await api("PUT", "/api/config", { automaticRecovery: { enabled: false, backup: { instanceId: "codex", model: "gpt-fake-default" } } }); const { bot } = await control("new-bot", "--name", "Sign in manually"); await api("PATCH", `/api/bots/${bot.id}/tasks/${bot.activeTaskId}`, { modelSelection: backup, approvalMode: "ask" }); await control("send", "--bot", bot.id, "--text", "NO_SIGN_OUT_RECOVERY_4B"); expect((await control("wait", "--bot", bot.id, "--timeout", "30")).status).toBe("failed"); const { messages } = await control("messages", "--bot", bot.id, "--limit", "30"); expect(messages.filter((message: any) => message.tool?.name?.startsWith("recovery:"))).toHaveLength(0); expect(messages.map((message: any) => message.tool).filter((tool: any) => tool?.ok === false)) .toEqual([{ name: "error: Not logged in · Please run /login", ok: false, setup: true }]); }); }, 90_000); it.each(["Stop", "new message"])("does not recover a superseded turn after %s", async (action) => { await withRecoveryFixture({ gated: true }, async ({ api, control, calls, backupPrompts, failureFile, gateFile }) => { const { bot } = await control("new-bot", "--name", "Respect changed intent"); const threadId = bot.activeTaskId; await api("PATCH", `/api/bots/${bot.id}/tasks/${threadId}`, { modelSelection: primary, updateBotDefault: true }); const pendingSend = control("send", "--bot", bot.id, "--task", threadId, "--text", "SUPERSEDED_WORK_5U"); await expect.poll(() => calls().some((call) => call.method === "initialize"), { timeout: 20_000 }).toBe(true); if (action === "Stop") await control("interrupt", "--bot", bot.id, "--task", threadId); else await control("send", "--bot", bot.id, "--task", threadId, "--text", "NEW_INTENT_6V"); // The process already holds the first failure. A new process will work; // this distinguishes draining the new message from replaying the old one. rmSync(failureFile); writeFileSync(gateFile, "release"); await pendingSend; if (action === "Stop") await control("send", "--bot", bot.id, "--task", threadId, "--text", "NEW_INTENT_6V"); const state = async () => (await api("GET", "/api/bots")).bots.find((entry: any) => entry.id === bot.id); await expect.poll(() => calls().filter((call) => call.method === "session/prompt.result").length, { timeout: 20_000 }).toBe(1); expect((await control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30")).status).toBe("settled"); expect(backupPrompts()).toHaveLength(0); expect((await state()).tasks.find((task: any) => task.threadId === threadId)).toMatchObject({ modelSelection: primary, busy: false }); const { messages } = await control("messages", "--bot", bot.id, "--task", threadId, "--limit", "20"); for (const text of ["SUPERSEDED_WORK_5U", "NEW_INTENT_6V"]) { expect(messages.filter((message: any) => message.role === "user" && message.text === text)).toHaveLength(1); } }); }, 90_000);