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:
@@ -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(字符串/转义感知,配平点必须在文本末尾),失败才进入修复流程。
|
||||
|
||||
确认语义:
|
||||
|
||||
|
||||
@@ -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,违规的丢弃并在事件文本里说明。
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+13
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user