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.
This commit is contained in:
zenord
2026-08-19 15:44:53 +08:00
parent 6b506b8c55
commit 9c3139380d
5 changed files with 183 additions and 11 deletions
+4 -3
View File
@@ -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(字符串/转义感知,配平点必须在文本末尾),失败才进入修复流程。
确认语义:
+1 -1
View File
@@ -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,违规的丢弃并在事件文本里说明。
+81 -7
View File
@@ -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 = "<GORI_ASSISTANT_ACTION_V2>";
const WORKER_START_TAG = "<GORI_WORKER_RESULT_V2>";
const ASSISTANT_ENVELOPE = /<GORI_ASSISTANT_ACTION_V2>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V2>\s*$/;
const WORKER_ENVELOPE = /<GORI_WORKER_RESULT_V2>(?<json>[\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<void> {
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: <GORI_WORKER_RESULT_V2>{\"status\":\"PENDING\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V2>. 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}`;
+84
View File
@@ -502,6 +502,39 @@ test("envelope parsers accept valid tails and reject invalid ones", () => {
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"send_image"}]}</GORI_ASSISTANT_ACTION_V2>`), 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<GORI_WORKER_RESULT_V2>{"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<GORI_WORKER_RESULT_V2>{"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<GORI_WORKER_RESULT_V2>{"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<GORI_WORKER_RESULT_V2>{"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<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s"}</GORI_WORKER_RESULT_V2> extra`), undefined);
assert.equal(parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s"} trailing`), undefined);
// Salvage never accepts an invalid payload even when the JSON balances.
assert.equal(parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"SUCCESS","summary":"s"}`), undefined);
assert.deepEqual(
parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"reply":"ok","actions":[]}`),
{ reply: "ok", actions: [] }
);
assert.equal(parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"reply":"ok","actions":[{"type":"stop"}`), undefined);
assert.equal(parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"reply":"ok","actions":[]}</GORI_ASSISTANT_ACTION_V2> more`), undefined);
assert.equal(parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"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 {
+13
View File
@@ -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<GORI_ASSISTANT_ACTION_V2>${JSON.stringify({ reply, actions })}</GORI_ASSISTANT_ACTION_V2>`;
const workerEnvelope = (result) => `worker reply\n<GORI_WORKER_RESULT_V2>${JSON.stringify(result)}</GORI_WORKER_RESULT_V2>`;
const workerEnvelopeTruncated = (result) => `worker reply\n<GORI_WORKER_RESULT_V2>${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<GORI_ASSISTANT_ACTION_V2>${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<GORI_WORKER_RESULT_V2>{"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" };