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 type { IncomingAttachment } from "../src/core/types.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; images?: { path: string; mimeType?: string; filename?: 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; images?: { path: string; mimeType?: string; filename?: string }[] }[] = []; const manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, store, proposals, { assistantWorkspaceHome: home, allowUnverifiedAssistantAgent: true }); manager.setEventSink(async (chatKey, text, images) => { events.push({ chatKey, text, images }); }); 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, images) => { harness.events.push({ chatKey, text, images }); }); 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", attachments?: IncomingAttachment[]) => ({ platform: "webhook", chatId: "chat-1", userId, text, attachments }); const pngAttachment = (): IncomingAttachment[] => [{ mimeType: "image/png", data: Buffer.from("fake-png").toString("base64"), filename: "fake.png" }]; 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; images?: number }[] { 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, "pending"); assert.equal(stopped.pending?.summary, "被用户中止"); assert.equal(stopped.pending?.workspaceDirty, true); assert.equal(stopped.workerProcessGroup, undefined); } 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 === "pending"); assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止"); } finally { await fs.promises.rm(gateFile, { force: true }); await closeHarness(harness); } }); test("pending never auto-finishes or auto-starts the next proposal; only finish settles it", 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 === "pending"); assert.equal(harness.proposals.get(first.id)!.pending?.summary, "goal done"); assert.equal(harness.proposals.get(first.id)!.finishedAt, undefined); assert.equal(harness.proposals.get(first.id)!.finishKind, undefined); // A new proposal can be queued while the owner has a pending one, but it must not start. 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, "pending"); await harness.manager.prompt(request("finish")); const finishedFirst = harness.proposals.get(first.id)!; assert.equal(finishedFirst.status, "finished"); assert.equal(finishedFirst.finishKind, "done"); assert.ok(finishedFirst.finishedAt); assert.equal(finishedFirst.pending, undefined); assert.equal(finishedFirst.lastWorkerSummary, "goal done"); 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 === "pending"); assert.equal(await harness.manager.finish("webhook", "chat-1", "user-1"), true); assert.equal(harness.proposals.get(second.id)!.status, "finished"); } finally { await closeHarness(harness); } }); test("follow_up on a pending proposal resumes the same worker session and carries image attachments", 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 === "pending"); const pendingCard = harness.proposals.get(proposal.id)!.pending!; assert.equal(pendingCard.question, "May I overwrite it?"); assert.equal(pendingCard.workspaceDirty, true); const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!; assert.ok(workerSessionId); await harness.manager.prompt(request("follow up: yes, overwrite it", "user-1", pngAttachment())); await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending"); const continued = harness.proposals.get(proposal.id)!; assert.equal(continued.workerNativeSessionId, workerSessionId); assert.match(continued.lastWorkerSummary || "", new RegExp(`continued ${workerSessionId}`)); assert.equal(continued.pending?.question, undefined); assert.equal(continued.pending?.workspaceDirty, undefined); const resumeEntries = readLog(harness.logFile).filter((entry) => entry.method === "session/resume" && entry.sessionId === workerSessionId); assert.ok(resumeEntries.length > 0); const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("The user sent a follow-up instruction")); assert.equal(followUpTurns.length, 1); assert.equal(followUpTurns[0]!.sessionId, workerSessionId); assert.equal(followUpTurns[0]!.images, 1); await harness.manager.prompt(request("finish")); assert.equal(harness.proposals.get(proposal.id)!.status, "finished"); assert.equal(harness.proposals.get(proposal.id)!.finishKind, "done"); } finally { await closeHarness(harness); } }); test("follow_up falls back to a fresh worker session with proposal context when resume fails", async () => { const harness = await createHarness({ agentEnv: { FAKE_ACP_RESUME_FAIL: "1", FAKE_ACP_LOAD_FAIL: "1" } }); 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 === "pending"); const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!; await harness.manager.prompt(request("follow up: yes, overwrite it")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending"); const continued = harness.proposals.get(proposal.id)!; assert.ok(continued.workerNativeSessionId); assert.notEqual(continued.workerNativeSessionId, workerSessionId); assert.match(continued.lastWorkerSummary || "", /continued/); const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("The user sent a follow-up instruction")); assert.equal(followUpTurns.length, 1); assert.match(followUpTurns[0]!.text || "", /fresh session continuing the same proposal/); } finally { await closeHarness(harness); } }); test("a fresh non-finish message reminds about the pending proposal when the assistant forgets it", 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 === "pending"); const forgotten = await harness.manager.prompt(request("hello")); assert.match(forgotten.text, /待确认的任务「Test proposal」/); assert.match(forgotten.text, /finish 结束/); const acknowledged = await harness.manager.prompt(request("ack pending")); assert.match(acknowledged.text, /任务「Test proposal」还在等你确认/); assert.doesNotMatch(acknowledged.text, /提醒:你还有/); const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello")); assert.equal(prompts.length, 1); assert.match(prompts[0]!.text!, /\[Pending proposals\]/); assert.match(prompts[0]!.text!, /summary="goal done"/); } finally { await closeHarness(harness); } }); test("adjust_proposal edits proposed or queued proposals but not pending ones", 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("adjust proposal: Better title")); assert.equal(harness.proposals.get(first.id)!.title, "Better title"); await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(first.id)!.status === "pending"); 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"); await harness.manager.prompt(request("adjust proposal: Queued title")); assert.equal(harness.proposals.get(second.id)!.title, "Queued title"); // With nothing proposed or queued left, the pending proposal cannot be adjusted. assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-1"), true); assert.equal(harness.proposals.get(second.id)!.status, "finished"); const rejected = await harness.manager.prompt(request("adjust proposal: Nope")); assert.match(rejected.text, /没有可调整的提案/); assert.equal(harness.proposals.get(first.id)!.title, "Better title"); } 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 === "pending"); 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 pending 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, "pending"); assert.match(recovered.pending?.summary || "", /worker_lost/); assert.equal(recovered.pending?.workspaceDirty, true); assert.match(recovered.lastWorkerSummary || "", /worker_lost/); 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, finish, 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.finish("webhook", "chat-1", "user-b"), false); 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, "pending"); assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止"); } 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 === "pending"); 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("finish", "user-a")); assert.equal(harness.proposals.get(first.id)!.status, "finished"); 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 === "pending"); } finally { await closeHarness(harness); } }); test("cancel settles proposed, queued, or pending proposals as cancelled and keeps finished history", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: succeed", "user-a")); const first = harness.proposals.list()[0]!; assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true); assert.equal(harness.proposals.get(first.id)!.status, "finished"); assert.equal(harness.proposals.get(first.id)!.finishKind, "cancelled"); 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")); await waitFor(() => harness.proposals.get(second.id)!.status === "pending"); assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true); const cancelled = harness.proposals.get(second.id)!; assert.equal(cancelled.status, "finished"); assert.equal(cancelled.finishKind, "cancelled"); assert.ok(cancelled.finishedAt); const finished = harness.manager.listProposals("webhook", "chat-1", "user-a"); assert.equal(finished.length, 2); assert.ok(finished.every((proposal) => proposal.status === "finished")); } 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.deepEqual( parseAssistantActions(`text\n{"reply":"hi","actions":[{"type":"follow_up","instruction":"keep going"},{"type":"finish","note":"done"}]}`), { reply: "hi", actions: [{ type: "follow_up", id: undefined, instruction: "keep going" }, { type: "finish", id: undefined, note: "done" }] } ); assert.equal(parseAssistantActions("no envelope"), undefined); assert.equal(parseAssistantActions(`x\n{"reply":"hi","actions":[{"type":"nuke"}]}`), undefined); assert.equal(parseAssistantActions(`x\n{"reply":"hi","actions":[]}`), undefined); assert.deepEqual( parseWorkerResult(`text\n{"status":"PENDING","summary":"s","question":"q","workspaceDirty":true}`), { status: "PENDING", summary: "s", question: "q", workspaceDirty: true, attachments: undefined } ); assert.deepEqual( parseWorkerResult(`text\n{"status":"PENDING","summary":"s","attachments":[{"path":".gori-outbox/a.png","mimeType":"image/png"}]}`), { status: "PENDING", summary: "s", question: undefined, workspaceDirty: undefined, attachments: [{ path: ".gori-outbox/a.png", mimeType: "image/png" }] } ); assert.equal(parseWorkerResult(`x\n{"status":"PENDING","summary":"s","attachments":[{"path":"a.png"},{"path":"b.png"},{"path":"c.png"},{"path":"d.png"}]}`), undefined); assert.equal(parseWorkerResult(`x\n{"status":"PENDING","summary":"s","attachments":[{"mimeType":"image/png"}]}`), undefined); assert.equal(parseWorkerResult(`x\n{"status":"SUCCESS","summary":"s"}`), undefined); assert.equal(parseWorkerResult(`x\n{"status":"PENDING","summary":"s"}`), undefined); assert.deepEqual( parseAssistantActions(`text\n{"reply":"hi","actions":[{"type":"send_image","path":".gori-outbox/a.png"}]}`), { reply: "hi", actions: [{ type: "send_image", path: ".gori-outbox/a.png" }] } ); assert.equal(parseAssistantActions(`x\n{"reply":"hi","actions":[{"type":"send_image"}]}`), undefined); }); test("envelope parsers salvage envelopes missing only the closing tag", () => { // Complete JSON truncated at the closing tag is accepted, attachments included. assert.deepEqual( parseWorkerResult(`worker reply\n{"status":"PENDING","summary":"s","attachments":[{"path":".gori-outbox/a.png"}]}`), { status: "PENDING", summary: "s", question: undefined, workspaceDirty: undefined, attachments: [{ path: ".gori-outbox/a.png", mimeType: undefined }] } ); // Braces and escapes inside strings do not confuse the balancing. assert.deepEqual( parseWorkerResult(`r\n{"status":"PENDING","summary":"use {a} and \\"}\\" end"}`), { status: "PENDING", summary: "use {a} and \"}\" end", question: undefined, workspaceDirty: undefined, attachments: undefined } ); // Trailing whitespace after the balanced JSON is fine, trailing content is not. assert.deepEqual( parseWorkerResult(`r\n{"status":"PENDING","summary":"s"}\n`), { status: "PENDING", summary: "s", question: undefined, workspaceDirty: undefined, attachments: undefined } ); // JSON cut off mid-object stays invalid. assert.equal(parseWorkerResult(`r\n{"status":"PENDING","summary":"cut off`), undefined); // Balanced JSON with trailing content (e.g. a closing tag plus more text) stays invalid. assert.equal(parseWorkerResult(`r\n{"status":"PENDING","summary":"s"} extra`), undefined); assert.equal(parseWorkerResult(`r\n{"status":"PENDING","summary":"s"} trailing`), undefined); // Salvage never accepts an invalid payload even when the JSON balances. assert.equal(parseWorkerResult(`r\n{"status":"SUCCESS","summary":"s"}`), undefined); assert.deepEqual( parseAssistantActions(`ok\n{"reply":"ok","actions":[]}`), { reply: "ok", actions: [] } ); assert.equal(parseAssistantActions(`ok\n{"reply":"ok","actions":[{"type":"stop"}`), undefined); assert.equal(parseAssistantActions(`ok\n{"reply":"ok","actions":[]} more`), undefined); assert.equal(parseAssistantActions(`ok\n{"reply":"ok","actions":[]} trailing`), undefined); }); test("an owner's own pending proposal blocks their start_next with an actionable correction", 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 === "pending"); 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"); const blocked = await harness.manager.prompt(request("start next", "user-a")); assert.match(blocked.text, /还不能开始/); assert.match(blocked.text, /先 finish 或直接继续说要求/); assert.match(blocked.text, /保留在队列里/); assert.equal(harness.proposals.get(second.id)!.status, "queued"); const status = harness.manager.status("webhook", "chat-1", "user-a"); assert.equal(status.schedulerState, "pending"); assert.equal(status.blockedReason, "your proposal is waiting for your decision"); assert.equal(status.myPendingProposals, 1); assert.equal(status.myQueuedProposals, 1); assert.equal(status.nextAction, "finish your pending proposal or follow up with new instructions"); } finally { await closeHarness(harness); } }); test("a clean pending proposal does not block other users, but a dirty one blocks everyone", async () => { const harness = await createHarness(); try { // user-a's clean pending: user-b's confirmed proposal starts immediately. await harness.manager.prompt(request("create proposal: succeed", "user-a")); const clean = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm", "user-a")); await waitFor(() => harness.proposals.get(clean.id)!.status === "pending"); assert.equal(harness.proposals.get(clean.id)!.pending?.workspaceDirty, undefined); await harness.manager.prompt(request("create proposal: succeed", "user-b")); const other = harness.proposals.list().find((proposal) => proposal.id !== clean.id)!; await harness.manager.prompt(request("confirm", "user-b")); assert.equal(harness.proposals.get(other.id)!.status, "working"); await waitFor(() => harness.proposals.get(other.id)!.status === "pending"); assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), true); await harness.manager.prompt(request("finish", "user-a")); assert.equal(harness.proposals.get(clean.id)!.status, "finished"); // user-a's dirty pending: user-b's start_next is globally blocked. await harness.manager.prompt(request("create proposal: ask", "user-a")); const dirty = harness.proposals.list().find((proposal) => proposal.status === "proposed")!; await harness.manager.prompt(request("confirm", "user-a")); await waitFor(() => harness.proposals.get(dirty.id)!.status === "pending"); assert.equal(harness.proposals.get(dirty.id)!.pending?.workspaceDirty, true); await harness.manager.prompt(request("create proposal: succeed", "user-b")); const blockedProposal = harness.proposals.list().find((proposal) => proposal.status === "proposed")!; await harness.manager.prompt(request("confirm", "user-b")); assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued"); const blocked = await harness.manager.prompt(request("start next", "user-b")); assert.match(blocked.text, /还不能开始/); assert.match(blocked.text, /dirty 工作区/); assert.doesNotMatch(blocked.text, new RegExp(dirty.title)); assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued"); const bStatus = harness.manager.status("webhook", "chat-1", "user-b"); assert.equal(bStatus.schedulerState, "pending"); assert.equal(bStatus.blockedReason, "a pending proposal left a dirty workspace"); await harness.manager.prompt(request("finish", "user-a")); assert.equal(harness.proposals.get(dirty.id)!.status, "finished"); await harness.manager.prompt(request("start next", "user-b")); assert.equal(harness.proposals.get(blockedProposal.id)!.status, "working"); await waitFor(() => harness.proposals.get(blockedProposal.id)!.status === "pending"); } finally { await closeHarness(harness); } }); test("a working proposal blocks start_next for everyone with a sanitized reason", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: hang", "user-a")); const first = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm", "user-a")); await waitFor(() => harness.proposals.get(first.id)!.status === "working"); await harness.manager.prompt(request("create proposal: succeed", "user-b")); const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!; await harness.manager.prompt(request("confirm", "user-b")); assert.equal(harness.proposals.get(second.id)!.status, "queued"); const blocked = await harness.manager.prompt(request("start next", "user-b")); assert.match(blocked.text, /还不能开始/); assert.match(blocked.text, /正在执行/); assert.equal(harness.proposals.get(second.id)!.status, "queued"); const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nstart next")); assert.ok(bPrompt?.text); assert.match(bPrompt.text!, /busy: another proposal is working/); const bStatus = harness.manager.status("webhook", "chat-1", "user-b"); assert.equal(bStatus.schedulerState, "working"); assert.equal(bStatus.blockedReason, "another proposal is working"); assert.equal(bStatus.nextAction, "wait for the current proposal to settle"); // follow_up is also globally blocked while a worker is running. assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true); assert.equal(harness.proposals.get(first.id)!.status, "pending"); } finally { await closeHarness(harness); } }); test("confirm, finish, stop, cancel, and follow_up actions with no matching proposal append an ownership correction", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: hang", "user-a")); const confirmed = await harness.manager.prompt(request("confirm", "user-b")); assert.match(confirmed.text, /没有可确认的提案/); assert.match(confirmed.text, /自己发起/); const finished = await harness.manager.prompt(request("finish", "user-b")); assert.match(finished.text, /没有可结束的任务/); assert.match(finished.text, /自己发起/); const stopped = await harness.manager.prompt(request("stop", "user-b")); assert.match(stopped.text, /没有可停止的任务/); assert.match(stopped.text, /自己发起/); const cancelled = await harness.manager.prompt(request("cancel proposal", "user-b")); assert.match(cancelled.text, /没有可取消的提案/); assert.match(cancelled.text, /自己发起/); const followedUp = await harness.manager.prompt(request("follow up: more", "user-b")); assert.match(followedUp.text, /现在没法继续/); assert.match(followedUp.text, /自己发起/); assert.equal(harness.proposals.list()[0]!.status, "proposed"); } finally { await closeHarness(harness); } }); test("start_next reports when the queue is empty instead of claiming a start", async () => { const harness = await createHarness(); try { const reply = await harness.manager.prompt(request("start next", "user-a")); assert.match(reply.text, /还不能开始/); assert.match(reply.text, /没有已确认并排队/); const status = harness.manager.status("webhook", "chat-1", "user-a"); assert.equal(status.schedulerState, "idle"); assert.equal(status.blockedReason, "none"); assert.equal(status.nextAction, "none"); } finally { await closeHarness(harness); } }); test("image attachments reach the assistant as image blocks when the agent supports them", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("hello", "user-1", pngAttachment())); const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello")); assert.equal(prompts.length, 1); assert.equal(prompts[0]!.images, 1); } finally { await closeHarness(harness); } }); test("image attachments degrade to a text note when the agent has no image capability", async () => { const harness = await createHarness({ agentEnv: { FAKE_ACP_IMAGE_CAP: "0" } }); try { await harness.manager.prompt(request("hello", "user-1", pngAttachment())); const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello")); assert.equal(prompts.length, 1); assert.equal(prompts[0]!.images, 0); assert.match(prompts[0]!.text!, /\[图片,当前 agent 不支持图片输入\]/); } finally { await closeHarness(harness); } }); test("a worker envelope truncated at the closing tag is salvaged without a repair round", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: truncattach")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending"); const pending = harness.proposals.get(proposal.id)!.pending!; assert.equal(pending.summary, "made a pic"); assert.equal(pending.attachments?.length, 1); assert.ok(pending.attachments![0]!.path.endsWith(path.join(".gori-outbox", "shot.png"))); const repairs = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V2")); assert.equal(repairs.length, 0); } finally { await closeHarness(harness); } }); test("a worker envelope with unbalanced JSON still goes through exactly one repair round", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: truncbad")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending"); assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "repaired"); const repairs = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V2")); assert.equal(repairs.length, 1); } finally { await closeHarness(harness); } }); test("an assistant envelope truncated at the closing tag is salvaged without a repair round", async () => { const harness = await createHarness(); try { const reply = await harness.manager.prompt(request("truncated assistant")); assert.equal(reply.text, "ok"); const repairs = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V2")); assert.equal(repairs.length, 0); } finally { await closeHarness(harness); } }); test("worker attachments are validated against the workspace and ride along with the owner event", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: attach")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending"); const pending = harness.proposals.get(proposal.id)!.pending!; assert.equal(pending.attachments?.length, 1); const stored = pending.attachments![0]!; assert.equal(stored.mimeType, "image/png"); const workspaceRoot = fs.realpathSync(harness.workspace); assert.ok(stored.path.startsWith(`${workspaceRoot}${path.sep}`), stored.path); assert.ok(stored.path.endsWith(path.join(".gori-outbox", "shot.png")), stored.path); assert.equal(pending.droppedAttachments, undefined); await waitFor(() => harness.events.length > 0); assert.equal(harness.events[0]!.images?.length, 1); assert.equal(harness.events[0]!.images![0]!.path, stored.path); assert.equal(harness.events[0]!.images![0]!.filename, "shot.png"); } finally { await closeHarness(harness); } }); test("worker attachments outside the workspace or not png/jpg are dropped without failing the result", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: attachbad")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending"); const pending = harness.proposals.get(proposal.id)!.pending!; assert.equal(pending.summary, "made a pic"); assert.equal(pending.attachments, undefined); assert.deepEqual(pending.droppedAttachments, ["/tmp/evil.png", "not-an-image.txt"]); await waitFor(() => harness.events.length > 0); assert.equal(harness.events[0]!.images, undefined); } finally { await closeHarness(harness); } }); test("send_image validates the path and attaches the image to the reply", async () => { const harness = await createHarness(); try { await harness.manager.prompt(request("create proposal: attach")); const proposal = harness.proposals.list()[0]!; await harness.manager.prompt(request("confirm")); await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending"); const sent = await harness.manager.prompt(request("send image: .gori-outbox/shot.png")); assert.equal(sent.images?.length, 1); assert.equal(sent.images![0]!.filename, "shot.png"); assert.equal(sent.images![0]!.mimeType, "image/png"); assert.ok(sent.images![0]!.path.startsWith(fs.realpathSync(harness.workspace))); assert.doesNotMatch(sent.text, /发不出去/); const outside = await harness.manager.prompt(request("send image: /tmp/evil.png")); assert.equal(outside.images, undefined); assert.match(outside.text, /发不出去/); const notImage = await harness.manager.prompt(request("send image: not-an-image.txt")); assert.equal(notImage.images, undefined); assert.match(notImage.text, /发不出去/); } finally { await closeHarness(harness); } }); function processGroupExists(pgid: number): boolean { try { process.kill(-pgid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } }