From 9c3139380d415f146d09720a09fef211435497e2 Mon Sep 17 00:00:00 2001 From: zenord Date: Wed, 19 Aug 2026 15:44:53 +0800 Subject: [PATCH] fix: salvage truncated worker envelopes and log worker lifecycle Models occasionally drop the closing tag of the result envelope while the JSON itself is complete; the strict parser rejected these and one repair attempt could not always recover, cascading into worker_error and dropping valid attachments. The parser now falls back to brace-balanced salvage when the closing tag is missing, and worker lifecycle events (start, resume, invalid envelope, repair, settle, worker_error) are logged. --- AGENTS.md | 7 +-- README.md | 2 +- src/acp/assistant-manager.ts | 88 +++++++++++++++++++++++++++++--- test/assistant-manager.test.ts | 84 ++++++++++++++++++++++++++++++ test/fixtures/fake-acp-agent.mjs | 13 +++++ 5 files changed, 183 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f619090..aff5831 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,9 @@ gori-agent list - 每个 ACP worker 使用独立进程组和随机 `GORI_AGENT_WORKER_TOKEN`;cancel、timeout、crash 或 assistant 隔离违约必须清理同进程组工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱组属于无 cgroup/Bubblewrap 方案的边界。 - assistant 只接受 Kimi Code ACP,并依赖项目级 `tools: []`、`subagents: []` profile;permission deny 只是附加层。 - `src/acp/assistant-manager.ts` - - 每 conversation(chat + user)一个无工具 Assistant 会话,同群不同用户互相隔离(`GORI_ASSISTANT_ACTION_V2` envelope:create_proposal/confirm/adjust_proposal/follow_up/finish/start_next/cancel/stop,格式只修复一次);runtime 执行 action 后在 reply 末尾追加人话纠正(如 start_next 被阻塞、无 owner 匹配),Assistant 不得自行宣称 action 已生效。 - - 唯一活跃 Worker 执行已确认 Proposal(`GORI_WORKER_RESULT_V2`:仅 `PENDING`,summary 必填,可带 question/workspaceDirty);pending 不区分 success/fail,只有 finish 落定为 finished(done),cancel 落定为 finished(cancelled),不自动开始下一个。 + - 每 conversation(chat + user)一个无工具 Assistant 会话,同群不同用户互相隔离(`GORI_ASSISTANT_ACTION_V2` envelope:create_proposal/confirm/adjust_proposal/follow_up/finish/send_image/start_next/cancel/stop,格式只修复一次);runtime 执行 action 后在 reply 末尾追加人话纠正(如 start_next 被阻塞、无 owner 匹配),Assistant 不得自行宣称 action 已生效。 + - 唯一活跃 Worker 执行已确认 Proposal(`GORI_WORKER_RESULT_V2`:仅 `PENDING`,summary 必填,可带 question/workspaceDirty/attachments);pending 不区分 success/fail,只有 finish 落定为 finished(done),cancel 落定为 finished(cancelled),不自动开始下一个。 + - envelope 解析先严格匹配(闭合标签 + 文本末尾 anchor);缺闭合标签时从起始标签后做花括号配平(字符串/转义感知)salvage,配平点必须在文本末尾才接受,否则仍判无效走修复。worker 启动(new/resume session)、envelope 无效、repair 成败、settle(attachments/dropped 数)、worker_error、follow_up resume/fresh 兜底均有单行安全日志(proposal id 前 8 位,不含正文)。 - capacity(maxAssistantSessions/maxProcesses)、idle sweep、cancel/confirm/finish/stop、worker_lost 恢复、owner 事件通知。 - `src/core/durable-session-store.ts` - state v3 只保存 bot/platform identity 和 assistant binding(conversation key,即 chat + user、agent ID、native session ID、assistant workspace、fingerprint、时间戳);不保存消息正文。 @@ -209,7 +210,7 @@ Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assi Proposal 状态流:`proposed → queued → working → pending → finished`。`proposed` 必须用户确认才进入 `queued`;`pending` 就是「等用户决定」,不区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel`(proposed/queued/pending)落定为 `finished(cancelled)`;没有 working、owner 自己没有 pending、全局没有 workspaceDirty pending 时,最早确认的 queued Proposal 才可通过 confirm 或 start_next 开始。 -Assistant 每条回复以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 仅 `create_proposal`、`confirm`、`adjust_proposal`(仅 proposed/queued)、`follow_up`(pending → working,优先 resume 原 worker native session,失败则带 Proposal 上下文新起 session,用户图片附件随 prompt 给 Worker)、`finish`、`send_image`(把 Worker 报告过的 workspace 内图片发给用户)、`start_next`、`cancel`、`stop`,格式错误只修复一次。Worker 每轮以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:仅 `PENDING`(`summary` 必填,可选 `question`、`workspaceDirty`、`attachments`),同样只修复一次。 +Assistant 每条回复以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 仅 `create_proposal`、`confirm`、`adjust_proposal`(仅 proposed/queued)、`follow_up`(pending → working,优先 resume 原 worker native session,失败则带 Proposal 上下文新起 session,用户图片附件随 prompt 给 Worker)、`finish`、`send_image`(把 Worker 报告过的 workspace 内图片发给用户)、`start_next`、`cancel`、`stop`,格式错误只修复一次。Worker 每轮以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:仅 `PENDING`(`summary` 必填,可选 `question`、`workspaceDirty`、`attachments`),同样只修复一次。envelope 缺闭合标签(模型截断)时先按花括号配平 salvage(字符串/转义感知,配平点必须在文本末尾),失败才进入修复流程。 确认语义: diff --git a/README.md b/README.md index 5c6098d..ce5f72c 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ QQ 出站图片:Worker/Assistant 报告的 workspace 内图片(png/jpg、≤ 运行链路是三层: -- **Assistant**:每个 conversation(chat + user)一个无工具 ACP 会话,只与用户对话;同群不同用户的会话互相隔离。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`adjust_proposal`、`follow_up`、`finish`、`send_image`、`start_next`、`cancel`、`stop`;格式错误只修复一次。`send_image { path }` 把 Worker 报告过的 workspace 内图片发给用户,与 Worker 附件同样的路径/类型/大小校验。 +- **Assistant**:每个 conversation(chat + user)一个无工具 ACP 会话,只与用户对话;同群不同用户的会话互相隔离。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V2` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`adjust_proposal`、`follow_up`、`finish`、`send_image`、`start_next`、`cancel`、`stop`;格式错误只修复一次(envelope 缺闭合标签时先按花括号配平 salvage,失败才修复)。`send_image { path }` 把 Worker 报告过的 workspace 内图片发给用户,与 Worker 附件同样的路径/类型/大小校验。 - **Proposal**:一份工作单,owner 是 chat + 发起用户;只有发起人本人可以 confirm/adjust/follow_up/finish/stop/cancel/list 它,`start_next` 也只启动发起人自己的 queued Proposal。状态流为 `proposed → queued → working → pending → finished`。`proposed` 只有用户确认后才进入 `queued`;`pending` 就是「等用户决定」,不再区分 success/failure;只有 `finish` 把 pending 落定为 `finished(done)`,`cancel` 落定为 `finished(cancelled)`。 - **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V2` envelope 收尾:`PENDING`(`summary` 必填,可带 `question`、`workspaceDirty`),不区分成功/失败,只把结果交给用户。Worker 给用户看的图片(png/jpg)必须保存在 workspace 内(建议 `.gori-outbox/`),并通过 `attachments: [{ path, mimeType? }]`(最多 3 个)上报;runtime 校验路径必须在 workspace 内、magic bytes 为 png/jpg、单张 ≤10MB,违规的丢弃并在事件文本里说明。 diff --git a/src/acp/assistant-manager.ts b/src/acp/assistant-manager.ts index bcd161f..4ae63a8 100644 --- a/src/acp/assistant-manager.ts +++ b/src/acp/assistant-manager.ts @@ -12,6 +12,8 @@ import type { AcpPromptContent } from "./client.js"; import { AcpSessionRestoreError, AcpWorker } from "./worker.js"; const ASSISTANT_POLICY: PermissionPolicy = { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }; +const ASSISTANT_START_TAG = ""; +const WORKER_START_TAG = ""; const ASSISTANT_ENVELOPE = /(?[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V2>\s*$/; const WORKER_ENVELOPE = /(?[\s\S]*?)<\/GORI_WORKER_RESULT_V2>\s*$/; @@ -355,6 +357,7 @@ export class AssistantManager implements ConversationRuntime { if (!proposal) return false; if (this.active || this.proposals.list({ status: "working" }).length > 0) return false; await this.proposals.update(proposal.id, { status: "working", pending: undefined, startedAt: Date.now() }); + console.log(`Worker follow-up for proposal ${proposalTag(proposal.id)}: resuming session ${proposal.workerNativeSessionId || "none"}`); this.launchWorker(proposal.id, { resumeSessionId: proposal.workerNativeSessionId, firstTurn: workerFollowUpPrompt(instruction, proposal.pending?.question), @@ -469,11 +472,14 @@ export class AssistantManager implements ConversationRuntime { } private async startWorkerRun(active: ActiveWorker, options: WorkerLaunchOptions): Promise { + let resumed = false; if (options.resumeSessionId) { try { await active.worker.start(options.resumeSessionId); + resumed = true; } catch (error) { if (!(error instanceof AcpSessionRestoreError) || this.active !== active || active.settled) throw error; + console.log(`Worker resume failed for proposal ${proposalTag(active.proposalId)}; starting a fresh session`); active.worker = this.spawnProposalWorker(active.proposalId); await active.worker.start(); options = { @@ -486,6 +492,7 @@ export class AssistantManager implements ConversationRuntime { await active.worker.start(); } if (this.active !== active || active.settled) throw new Error("Worker was superseded before its session started"); + console.log(`Worker started for proposal ${proposalTag(active.proposalId)} (session ${active.worker.nativeSessionId}, ${resumed ? "resumed" : "new"})`); await this.proposals.update(active.proposalId, { workerNativeSessionId: active.worker.nativeSessionId }); await active.worker.prompt(this.bot.workerBootstrap, "initializing"); if (this.active !== active || active.settled) throw new Error("Worker was superseded during bootstrap"); @@ -500,11 +507,16 @@ export class AssistantManager implements ConversationRuntime { if (this.active !== active || active.settled) throw new Error("Worker was superseded during its turn"); let result = parseWorkerResult(reply); if (!result) { + console.error(`Worker turn for proposal ${proposalTag(active.proposalId)} returned no valid envelope (strict or salvaged); requesting repair`); const repaired = await active.worker.prompt(workerRepairPrompt()); if (this.active !== active || active.settled) throw new Error("Worker was superseded during result repair"); result = parseWorkerResult(repaired); + if (result) console.log(`Worker result repair succeeded for proposal ${proposalTag(active.proposalId)}`); + } + if (!result) { + console.error(`Worker result repair failed for proposal ${proposalTag(active.proposalId)}`); + throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V2 envelope after one repair attempt"); } - if (!result) throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V2 envelope after one repair attempt"); await this.settleWorkerResult(active, result); } @@ -548,6 +560,7 @@ export class AssistantManager implements ConversationRuntime { pending, lastWorkerSummary: result.summary }); + console.log(`Worker settled proposal ${proposalTag(active.proposalId)} as pending (attachments=${attachments.length} dropped=${droppedAttachments.length})`); await active.worker.terminate().catch(() => undefined); await this.proposals.update(active.proposalId, { workerProcessGroup: undefined }); return true; @@ -566,6 +579,7 @@ export class AssistantManager implements ConversationRuntime { active.settled = true; this.active = undefined; const summary = `worker_error: ${error instanceof Error ? error.message : String(error)}`; + console.error(`Worker error for proposal ${proposalTag(active.proposalId)}: ${error instanceof Error ? error.message : String(error)}`); await this.proposals.update(active.proposalId, { status: "pending", pending: { summary, workspaceDirty: true, receivedAt: Date.now() }, @@ -816,10 +830,22 @@ export function assistantKeyFor(chatKey: string, userId: string): string { return `${chatKey}#${userId}`; } -export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined { const match = ASSISTANT_ENVELOPE.exec(text); - if (!match?.groups) return undefined; +function proposalTag(proposalId: string): string { + return proposalId.slice(0, 8); +} + +export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined { + const direct = parseAssistantEnvelopeValue(strictEnvelopeJson(text, ASSISTANT_ENVELOPE)); + if (direct) return direct; + const salvaged = parseAssistantEnvelopeValue(salvageEnvelopeJson(text, ASSISTANT_START_TAG)); + if (salvaged) console.log("Assistant action envelope salvaged (missing closing tag)"); + return salvaged; +} + +function parseAssistantEnvelopeValue(json: string | undefined): ParsedAssistantEnvelope | undefined { + if (!json) return undefined; let value: unknown; - try { value = JSON.parse(match.groups.json); } catch { return undefined; } + try { value = JSON.parse(json); } catch { return undefined; } if (!isRecord(value) || typeof value.reply !== "string" || !Array.isArray(value.actions)) return undefined; const actions: AssistantAction[] = []; for (const item of value.actions) { @@ -878,10 +904,17 @@ function parseAssistantAction(value: unknown): AssistantAction | undefined { } export function parseWorkerResult(text: string): ParsedWorkerResult | undefined { - const match = WORKER_ENVELOPE.exec(text); - if (!match?.groups) return undefined; + const direct = parseWorkerResultValue(strictEnvelopeJson(text, WORKER_ENVELOPE)); + if (direct) return direct; + const salvaged = parseWorkerResultValue(salvageEnvelopeJson(text, WORKER_START_TAG)); + if (salvaged) console.log("Worker result envelope salvaged (missing closing tag)"); + return salvaged; +} + +function parseWorkerResultValue(json: string | undefined): ParsedWorkerResult | undefined { + if (!json) return undefined; let value: unknown; - try { value = JSON.parse(match.groups.json); } catch { return undefined; } + try { value = JSON.parse(json); } catch { return undefined; } if (!isRecord(value) || typeof value.summary !== "string") return undefined; if (value.status !== "PENDING") return undefined; if ((value.question !== undefined && typeof value.question !== "string") @@ -905,6 +938,47 @@ export function parseWorkerResult(text: string): ParsedWorkerResult | undefined }; } +function strictEnvelopeJson(text: string, envelope: RegExp): string | undefined { + return envelope.exec(text)?.groups?.json; +} + +// Fallback for model responses truncated at the envelope's closing tag: brace-balance the JSON +// after the start tag (string- and escape-aware) and accept it only when the balanced object +// ends exactly at the end of the text. Anything unbalanced or with trailing content stays invalid. +function salvageEnvelopeJson(text: string, startTag: string): string | undefined { + const start = text.lastIndexOf(startTag); + if (start < 0) return undefined; + const after = text.slice(start + startTag.length); + if (!after.startsWith("{")) return undefined; + const end = balancedJsonObjectEnd(after); + if (end === undefined) return undefined; + if (after.slice(end).trim().length > 0) return undefined; + return after.slice(0, end); +} + +function balancedJsonObjectEnd(text: string): number | undefined { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index++) { + const char = text[index]!; + if (inString) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === "{") depth++; + else if (char === "}") { + depth--; + if (depth === 0) return index + 1; + if (depth < 0) return undefined; + } + } + return undefined; +} + function withWorkerResultProtocol(input: string | AcpPromptContent[]): string | AcpPromptContent[] { const instruction = "When this turn is finished, end your response with exactly one hidden worker result envelope: {\"status\":\"PENDING\",\"summary\":\"...\"}. The summary is a short user-readable description of what you did or what is blocking you. Add a \"question\" when you need the user's decision before continuing, and set \"workspaceDirty\": true when you left the workspace modified or are unsure about its state. When you produced image files the user should see (png/jpg only), save them inside the workspace (prefer .gori-outbox/) and report up to 3 of them as \"attachments\": [{\"path\":\"relative/or/absolute/path\"}]; never report paths outside the workspace. PENDING is the only status; do not emit any other status value or any text after the envelope."; if (typeof input === "string") return `${input}\n\n${instruction}`; diff --git a/test/assistant-manager.test.ts b/test/assistant-manager.test.ts index 89d39da..a3cfc6c 100644 --- a/test/assistant-manager.test.ts +++ b/test/assistant-manager.test.ts @@ -502,6 +502,39 @@ test("envelope parsers accept valid tails and reject invalid ones", () => { 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 { @@ -689,6 +722,57 @@ test("image attachments degrade to a text note when the agent has no image capab } }); +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 { diff --git a/test/fixtures/fake-acp-agent.mjs b/test/fixtures/fake-acp-agent.mjs index 324cdc3..d3b5158 100644 --- a/test/fixtures/fake-acp-agent.mjs +++ b/test/fixtures/fake-acp-agent.mjs @@ -12,6 +12,7 @@ const logFile = process.env.FAKE_ACP_LOG; const log = (entry) => { if (logFile) fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`); }; const assistantEnvelope = (reply, actions) => `${reply}\n${JSON.stringify({ reply, actions })}`; const workerEnvelope = (result) => `worker reply\n${JSON.stringify(result)}`; +const workerEnvelopeTruncated = (result) => `worker reply\n${JSON.stringify(result)}`; const app = acp.agent({ name: "fake-acp-agent" }) .onRequest(acp.methods.agent.initialize, ({ params }) => { @@ -92,6 +93,7 @@ const app = acp.agent({ name: "fake-acp-agent" }) else if (userText === "stop") response = assistantEnvelope("stopping", [{ type: "stop" }]); else if (userText === "cancel proposal") response = assistantEnvelope("cancelling", [{ type: "cancel" }]); else if (userText === "ack pending") response = assistantEnvelope("任务「Test proposal」还在等你确认。", []); + else if (userText === "truncated assistant") response = `ok\n${JSON.stringify({ reply: "ok", actions: [] })}`; else if (userText === "invalid assistant") response = "invalid without envelope"; else response = assistantEnvelope("ok", []); await update(client, params.sessionId, response); @@ -121,6 +123,17 @@ const app = acp.agent({ name: "fake-acp-agent" }) if (process.env.FAKE_ACP_WORKER_GATE_FILE) { while (!fs.existsSync(process.env.FAKE_ACP_WORKER_GATE_FILE)) await new Promise((resolve) => setTimeout(resolve, 5)); } + if (goal.includes("truncbad")) { + await update(client, params.sessionId, `worker reply\n{"status":"PENDING","summary":"cut off mid json"`); + return { stopReason: "end_turn" }; + } + if (goal.includes("truncattach")) { + const outbox = path.join(process.cwd(), ".gori-outbox"); + fs.mkdirSync(outbox, { recursive: true }); + fs.writeFileSync(path.join(outbox, "shot.png"), Buffer.concat([PNG_HEADER, Buffer.from("fake-png-payload-truncated")])); + await update(client, params.sessionId, workerEnvelopeTruncated({ status: "PENDING", summary: "made a pic", attachments: [{ path: ".gori-outbox/shot.png", mimeType: "image/png" }] })); + return { stopReason: "end_turn" }; + } if (goal.includes("attachbad")) { await update(client, params.sessionId, workerEnvelope({ status: "PENDING", summary: "made a pic", attachments: [{ path: "/tmp/evil.png" }, { path: "not-an-image.txt" }] })); return { stopReason: "end_turn" };