import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { AssistantManager, assistantKeyFor, parseAssistantActions, parseWorkerResult } from "../src/acp/assistant-manager.js"; import { parseConfig } from "../src/config.js"; import { DurableSessionStore } from "../src/core/durable-session-store.js"; import { ProposalStore } from "../src/core/proposal-store.js"; import { BotProfileResolver } from "../src/roles/role-registry.js"; const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs"); const CHAT_KEY = "webhook:chat-1"; const CONVERSATION_KEY = assistantKeyFor(CHAT_KEY, "user-1"); interface Harness { home: string; workspace: string; store: DurableSessionStore; proposals: ProposalStore; manager: AssistantManager; events: { chatKey: string; text: string }[]; logFile: string; } async function createHarness(options: { initialize?: boolean; agentEnv?: Record } = {}): Promise { const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-home-")); const workspace = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-ws-")); const logFile = path.join(home, "fake-acp.log"); const config = parseConfig({ configVersion: 3, bot: { id: "test-bot", workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...options.agentEnv } }, permissions: { mode: "deny" } }, gateway: { platform: { type: "webhook", secret: "secret" } }, runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } } }); const identity = { botId: "test-bot", platform: "webhook" }; const store = new DurableSessionStore(path.join(home, "state", "acp-sessions.json"), identity); const proposals = new ProposalStore(path.join(home, "state", "proposals.json"), identity); await store.open(); await proposals.open(); const events: { chatKey: string; text: string }[] = []; const manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, store, proposals, { assistantWorkspaceHome: home, allowUnverifiedAssistantAgent: true }); manager.setEventSink(async (chatKey, text) => { events.push({ chatKey, text }); }); if (options.initialize !== false) await manager.initialize(); return { home, workspace, store, proposals, manager, events, logFile }; } async function reopenManager(harness: Harness): Promise { await harness.manager.shutdown().catch(() => undefined); await harness.proposals.close().catch(() => undefined); await harness.store.close().catch(() => undefined); const identity = { botId: "test-bot", platform: "webhook" }; harness.store = new DurableSessionStore(path.join(harness.home, "state", "acp-sessions.json"), identity); harness.proposals = new ProposalStore(path.join(harness.home, "state", "proposals.json"), identity); await harness.store.open(); await harness.proposals.open(); const config = parseConfig({ configVersion: 3, bot: { id: "test-bot", workspace: harness.workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: harness.logFile } }, permissions: { mode: "deny" } }, gateway: { platform: { type: "webhook", secret: "secret" } }, runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } } }); harness.manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, harness.store, harness.proposals, { assistantWorkspaceHome: harness.home, allowUnverifiedAssistantAgent: true }); harness.manager.setEventSink(async (chatKey, text) => { harness.events.push({ chatKey, text }); }); await harness.manager.initialize(); } async function closeHarness(harness: Harness): Promise { await harness.manager.shutdown().catch(() => undefined); await harness.proposals.close().catch(() => undefined); await harness.store.close().catch(() => undefined); await fs.promises.rm(harness.home, { recursive: true, force: true }); await fs.promises.rm(harness.workspace, { recursive: true, force: true }); } const request = (text: string, userId = "user-1") => ({ platform: "webhook", chatId: "chat-1", userId, text }); async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise { const deadline = Date.now() + timeoutMs; while (!condition()) { if (Date.now() > deadline) throw new Error("condition not met before timeout"); await new Promise((resolve) => setTimeout(resolve, 10)); } } function readLog(logFile: string): { method: string; sessionId?: string; cwd?: string; text?: string }[] { if (!fs.existsSync(logFile)) return []; return fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as { method: string }); } test("create proposal stays proposed until confirm; confirm starts the worker without waiting for it", async () => { const harness = await createHarness(); try { const created = await harness.manager.prompt(request("create proposal: hang")); assert.equal(created.mode, "assistant"); const [proposal] = harness.proposals.list(); assert.ok(proposal); assert.equal(proposal.status, "proposed"); assert.equal(proposal.ownerChatKey, CHAT_KEY); assert.equal(readLog(harness.logFile).filter((entry) => entry.method === "session/new" && entry.cwd === harness.workspace).length, 0); assert.ok(harness.store.getBinding(CONVERSATION_KEY)); await harness.manager.prompt(request("confirm")); const working = harness.proposals.get(proposal.id)!; assert.equal(working.status, "working"); assert.ok(working.confirmedAt); assert.ok(working.startedAt); await waitFor(() => Boolean(harness.proposals.get(proposal.id)!.workerProcessGroup)); assert.equal(harness.proposals.get(proposal.id)!.status, "working"); await harness.manager.prompt(request("stop")); const stopped = harness.proposals.get(proposal.id)!; assert.equal(stopped.status, "cancelled"); assert.equal(stopped.workerProcessGroup, undefined); assert.ok(stopped.finishedAt); } finally { await closeHarness(harness); } }); test("stop does not deadlock while a worker result is waiting to settle", async () => { const gateFile = path.join(os.tmpdir(), `gori-worker-gate-${crypto.randomUUID()}`); const harness = await createHarness({ agentEnv: { FAKE_ACP_WORKER_GATE_FILE: gateFile } }); try { await harness.manager.prompt(request("create proposal: succeed")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => readLog(harness.logFile).some((entry) => entry.method === "session/prompt" && entry.text?.startsWith("Execute this confirmed proposal"))); type Scheduler = (operation: () => Promise | T) => Promise; const internals = harness.manager as unknown as { scheduler: Scheduler }; const original = internals.scheduler.bind(harness.manager); let intercepted = false; let notifySettle!: () => void; let startSettle: (() => Promise) | undefined; const settleReached = new Promise((resolve) => { notifySettle = resolve; }); internals.scheduler = (operation: () => Promise | T): Promise => { if (!intercepted) { intercepted = true; return new Promise((resolve, reject) => { notifySettle(); startSettle = () => original(operation).then(resolve, reject); }); } return original(operation); }; fs.writeFileSync(gateFile, "go\n", { mode: 0o600 }); await settleReached; const stopped = harness.manager.stop("webhook", "chat-1", "user-1"); assert.ok(startSettle); void startSettle(); assert.equal(await stopped, true); await waitFor(() => harness.proposals.get(proposal.id)!.status === "cancelled"); assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled"); } finally { await fs.promises.rm(gateFile, { force: true }); await closeHarness(harness); } }); test("SUCCESS waits for user confirmation and never auto-completes or auto-starts the next proposal", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: succeed")); const first = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation"); assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success"); assert.equal(harness.proposals.get(first.id)!.finishedAt, undefined); await harness.manager.prompt(request("create proposal: succeed")); const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!; await harness.manager.prompt(request("confirm")); assert.equal(harness.proposals.get(second.id)!.status, "queued"); assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation"); await harness.manager.prompt(request("confirm")); assert.equal(harness.proposals.get(first.id)!.status, "completed"); assert.ok(harness.proposals.get(first.id)!.finishedAt); assert.equal(harness.proposals.get(second.id)!.status, "queued"); await harness.manager.prompt(request("start next")); assert.equal(harness.proposals.get(second.id)!.status, "working"); await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation"); await harness.manager.prompt(request("confirm")); assert.equal(harness.proposals.get(second.id)!.status, "completed"); } finally { await closeHarness(harness); } }); test("FAILED requires confirmation; confirm ends it and confirm retry restarts a working worker", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: fail")); const first = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation"); assert.equal(harness.proposals.get(first.id)!.pending?.kind, "failure"); await harness.manager.prompt(request("confirm")); assert.equal(harness.proposals.get(first.id)!.status, "failed"); assert.ok(harness.proposals.get(first.id)!.finishedAt); await harness.manager.prompt(request("create proposal: fail")); const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation"); await harness.manager.prompt(request("confirm retry")); assert.equal(harness.proposals.get(second.id)!.status, "working"); await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation"); assert.equal(harness.proposals.get(second.id)!.pending?.kind, "failure"); await harness.manager.prompt(request("confirm")); assert.equal(harness.proposals.get(second.id)!.status, "failed"); } finally { await closeHarness(harness); } }); test("NEEDS_CONFIRMATION answer continues the same worker session", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: ask")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation"); const pendingStep = harness.proposals.get(proposal.id)!.pending!; assert.equal(pendingStep.kind, "step"); assert.equal(pendingStep.question, "May I overwrite it?"); const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!; assert.ok(workerSessionId); await harness.manager.prompt(request("answer: yes, overwrite it")); await waitFor(() => harness.proposals.get(proposal.id)!.pending?.kind === "success"); const continued = harness.proposals.get(proposal.id)!; assert.equal(continued.workerNativeSessionId, workerSessionId); assert.match(continued.lastWorkerSummary || "", new RegExp(`continued ${workerSessionId}`)); const workerPrompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text && (entry.text.startsWith("Execute this confirmed proposal") || entry.text.startsWith("The user answered your question"))); assert.equal(workerPrompts.length, 2); assert.ok(workerPrompts.every((entry) => entry.sessionId === workerSessionId)); await harness.manager.prompt(request("confirm")); assert.equal(harness.proposals.get(proposal.id)!.status, "completed"); } finally { await closeHarness(harness); } }); test("assistant tool activity fails closed and the next turn starts a clean session", async () => { const harness = await createHarness(); try { await assert.rejects(harness.manager.prompt(request("force tool")), /forbidden tool activity/); assert.equal(harness.store.getBinding(CONVERSATION_KEY), undefined); const ok = await harness.manager.prompt(request("hello")); assert.equal(ok.text, "ok"); assert.ok(harness.store.getBinding(CONVERSATION_KEY)); } finally { await closeHarness(harness); } }); test("worker completion notifies the owner chat through the event sink", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: succeed")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation"); await waitFor(() => harness.events.length > 0); assert.equal(harness.events[0]!.chatKey, CHAT_KEY); assert.match(harness.events[0]!.text, /event received: worker update/); } finally { await closeHarness(harness); } }); test("restart recovery kills the persisted worker group and marks the working proposal failed as worker_lost", async () => { const harness = await createHarness(); const token = crypto.randomUUID(); const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore", env: { ...process.env, GORI_AGENT_WORKER_TOKEN: token } }); child.unref(); try { const proposal = await harness.proposals.create({ title: "lost", goal: "lost", steps: ["step"], ownerChatKey: CHAT_KEY, requesterUserId: "user-1" }); await harness.proposals.update(proposal.id, { status: "working", startedAt: Date.now(), workerProcessGroup: { pgid: child.pid!, token } }); assert.equal(processGroupExists(child.pid!), true); await reopenManager(harness); const recovered = harness.proposals.get(proposal.id)!; assert.equal(recovered.status, "failed"); assert.match(recovered.pending?.summary || "", /worker_lost/); assert.match(recovered.lastWorkerSummary || "", /worker_lost/); assert.ok(recovered.finishedAt); await waitFor(() => !processGroupExists(child.pid!)); } finally { try { process.kill(-child.pid!, "SIGKILL"); } catch { /* already dead */ } await closeHarness(harness); } }); test("proposals are owned per chat+user: a second user in the same chat cannot confirm, stop, cancel, or list them", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: hang", "user-a")); await harness.manager.prompt(request("hello", "user-b")); assert.equal(harness.store.stats().bindings, 2); const proposal = harness.proposals.list()[0]!; assert.equal(proposal.status, "proposed"); assert.equal(proposal.requesterUserId, "user-a"); assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-b"), false); await harness.manager.prompt(request("confirm", "user-b")); assert.equal(harness.proposals.get(proposal.id)!.status, "proposed"); assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-b"), false); await harness.manager.prompt(request("cancel proposal", "user-b")); assert.equal(harness.proposals.get(proposal.id)!.status, "proposed"); assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false); assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 0); assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").length, 1); assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-a"), true); assert.equal(harness.proposals.get(proposal.id)!.status, "working"); await harness.manager.prompt(request("hello again", "user-b")); const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello again")); assert.ok(bPrompt?.text); assert.match(bPrompt.text!, /details hidden/); assert.doesNotMatch(bPrompt.text!, /Test proposal/); assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false); assert.equal(harness.proposals.get(proposal.id)!.status, "working"); assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true); assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled"); } finally { await closeHarness(harness); } }); test("start_next only starts the requesting user's own queued proposal", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: succeed", "user-a")); const first = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm", "user-a")); await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation"); await harness.manager.prompt(request("create proposal: succeed", "user-a")); const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!; await harness.manager.prompt(request("confirm", "user-a")); assert.equal(harness.proposals.get(second.id)!.status, "queued"); await harness.manager.prompt(request("confirm", "user-a")); assert.equal(harness.proposals.get(first.id)!.status, "completed"); assert.equal(harness.proposals.get(second.id)!.status, "queued"); await harness.manager.prompt(request("start next", "user-b")); assert.equal(harness.proposals.get(second.id)!.status, "queued"); await harness.manager.prompt(request("start next", "user-a")); assert.equal(harness.proposals.get(second.id)!.status, "working"); await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation"); } finally { await closeHarness(harness); } }); test("stop settles the latest awaiting proposal (success completes, failure fails, step cancels) without auto-starting the next", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: succeed", "user-a")); const first = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm", "user-a")); await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation"); assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success"); await harness.manager.prompt(request("create proposal: succeed", "user-a")); const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!; await harness.manager.prompt(request("confirm", "user-a")); assert.equal(harness.proposals.get(second.id)!.status, "queued"); await harness.manager.prompt(request("stop", "user-a")); assert.equal(harness.proposals.get(first.id)!.status, "completed"); assert.ok(harness.proposals.get(first.id)!.finishedAt); assert.equal(harness.proposals.get(second.id)!.status, "queued"); assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false); assert.equal(harness.proposals.get(second.id)!.status, "queued"); await harness.manager.prompt(request("start next", "user-a")); await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation"); assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true); assert.equal(harness.proposals.get(second.id)!.status, "completed"); await harness.manager.prompt(request("create proposal: fail", "user-a")); const third = harness.proposals.list().find((proposal) => proposal.id !== first.id && proposal.id !== second.id)!; await harness.manager.prompt(request("confirm", "user-a")); await waitFor(() => harness.proposals.get(third.id)!.status === "awaiting_user_confirmation"); assert.equal(harness.proposals.get(third.id)!.pending?.kind, "failure"); assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true); assert.equal(harness.proposals.get(third.id)!.status, "failed"); await harness.manager.prompt(request("create proposal: ask", "user-a")); const fourth = harness.proposals.list().find((proposal) => proposal.status === "proposed")!; await harness.manager.prompt(request("confirm", "user-a")); await waitFor(() => harness.proposals.get(fourth.id)!.status === "awaiting_user_confirmation"); assert.equal(harness.proposals.get(fourth.id)!.pending?.kind, "step"); assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true); assert.equal(harness.proposals.get(fourth.id)!.status, "cancelled"); assert.equal(harness.manager.status("webhook", "chat-1", "user-a").workerRunning, false); } finally { await closeHarness(harness); } }); test("envelope parsers accept valid tails and reject invalid ones", () => { assert.deepEqual( parseAssistantActions(`text\n{"reply":"hi","actions":[{"type":"stop"}]}`), { reply: "hi", actions: [{ type: "stop" }] } ); assert.equal(parseAssistantActions("no envelope"), undefined); assert.equal(parseAssistantActions(`x\n{"reply":"hi","actions":[{"type":"nuke"}]}`), undefined); assert.deepEqual( parseWorkerResult(`text\n{"status":"NEEDS_CONFIRMATION","summary":"s","question":"q","dirty":true}`), { status: "NEEDS_CONFIRMATION", summary: "s", question: "q", nextStep: undefined, dirty: true } ); assert.equal(parseWorkerResult(`x\n{"status":"DONE","summary":"s"}`), undefined); }); function processGroupExists(pgid: number): boolean { try { process.kill(-pgid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } }