feat: add assistant proposal worker runtime

This commit is contained in:
zenord
2026-08-18 18:48:47 +08:00
parent 947f19e3cd
commit b4121c9fd6
32 changed files with 2827 additions and 986 deletions
+93 -18
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
@@ -6,6 +7,8 @@ import * as acp from "@agentclientprotocol/sdk";
const pending = new Map();
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_V1>${JSON.stringify({ reply, actions })}</GORI_ASSISTANT_ACTION_V1>`;
const workerEnvelope = (result) => `worker reply\n<GORI_WORKER_RESULT_V1>${JSON.stringify(result)}</GORI_WORKER_RESULT_V1>`;
const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.initialize, ({ params }) => {
@@ -16,9 +19,10 @@ const app = acp.agent({ name: "fake-acp-agent" })
agentInfo: { name: "fake-acp-agent", version: "1" }
};
})
.onRequest(acp.methods.agent.session.new, ({ params }) => {
.onRequest(acp.methods.agent.session.new, async ({ params }) => {
const sessionId = `fake-${Date.now()}-${Math.random().toString(16).slice(2)}`;
log({ method: "session/new", sessionId, cwd: params.cwd });
if (process.env.FAKE_ACP_NEW_HANG === "1") await new Promise(() => undefined);
return { sessionId };
})
.onRequest(acp.methods.agent.session.load, ({ params }) => {
@@ -36,20 +40,94 @@ const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => {
const text = params.prompt.filter((item) => item.type === "text").map((item) => item.text).join("");
log({ method: "session/prompt", sessionId: params.sessionId, text });
if (text === "crash") process.exit(9);
if (text === "permission") {
const response = await client.request(acp.methods.client.session.requestPermission, {
sessionId: params.sessionId,
toolCall: { toolCallId: "bash-1", title: "bash git status", kind: "execute", name: "bash", rawInput: "git status" },
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" }
]
});
await update(client, params.sessionId, response.outcome.outcome === "selected" ? response.outcome.optionId : "cancelled");
if (text === "crash" || text.startsWith("crash\n\nWhen this turn")) process.exit(9);
if (text.includes("Initialize this ACP session")) {
if (process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
await update(client, params.sessionId, "READY");
return { stopReason: "end_turn" };
}
if (text === "hang") {
// Assistant protocol
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1")) {
await update(client, params.sessionId, assistantEnvelope("repaired", []));
return { stopReason: "end_turn" };
}
if (text.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V1")) {
await update(client, params.sessionId, process.env.FAKE_ACP_INVALID_REPAIR === "1"
? "still invalid"
: workerEnvelope({ status: "SUCCESS", summary: "repaired" }));
return { stopReason: "end_turn" };
}
if (text.includes("[Internal event")) {
await update(client, params.sessionId, assistantEnvelope("event received: worker update", []));
return { stopReason: "end_turn" };
}
if (text.includes("[User message]")) {
const userText = (text.split("[User message]\n")[1] || "").split("\n\n")[0].trim();
if (userText === "force tool") {
await client.notify(acp.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "tool_call", toolCallId: "read-1", title: "Read a file", kind: "read", status: "in_progress" }
});
return { stopReason: "end_turn" };
}
let response;
if (userText.startsWith("create proposal:")) {
const goal = userText.slice("create proposal:".length).trim();
response = assistantEnvelope("proposal drafted", [{ type: "create_proposal", title: "Test proposal", goal, steps: ["step 1"] }]);
} else if (userText === "confirm") response = assistantEnvelope("confirmed", [{ type: "confirm" }]);
else if (userText === "confirm retry") response = assistantEnvelope("retrying", [{ type: "confirm", answer: "retry" }]);
else if (userText.startsWith("answer:")) response = assistantEnvelope("answering", [{ type: "confirm", answer: userText.slice("answer:".length).trim() }]);
else if (userText === "start next") response = assistantEnvelope("starting next", [{ type: "start_next" }]);
else if (userText === "stop") response = assistantEnvelope("stopping", [{ type: "stop" }]);
else if (userText === "cancel proposal") response = assistantEnvelope("cancelling", [{ type: "cancel" }]);
else if (userText === "invalid assistant") response = "invalid without envelope";
else response = assistantEnvelope("ok", []);
await update(client, params.sessionId, response);
return { stopReason: "end_turn" };
}
// Worker protocol
if (text.startsWith("The user answered your question")) {
await update(client, params.sessionId, workerEnvelope({ status: "SUCCESS", summary: `continued ${params.sessionId}` }));
return { stopReason: "end_turn" };
}
if (text.startsWith("Execute this confirmed proposal")) {
const goal = ((text.split("Goal: ")[1] || "").split("\n")[0] || "").trim();
if (goal.includes("hang")) {
await new Promise((resolve) => {
const done = () => resolve(undefined);
pending.set(params.sessionId, done);
signal.addEventListener("abort", done, { once: true });
});
pending.delete(params.sessionId);
return { stopReason: "cancelled" };
}
if (goal.includes("invalid")) {
await update(client, params.sessionId, "invalid without envelope");
return { stopReason: "end_turn" };
}
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));
}
const result = goal.includes("ask")
? { status: "NEEDS_CONFIRMATION", summary: "hit a dirty target", question: "May I overwrite it?", nextStep: "overwrite it", dirty: true }
: goal.includes("fail")
? { status: "FAILED", summary: "something broke" }
: { status: "SUCCESS", summary: "goal done" };
await update(client, params.sessionId, workerEnvelope(result));
return { stopReason: "end_turn" };
}
// Legacy behaviors used by acp-worker/acp-client tests
const request = text.split("\n\nWhen this turn is finished")[0];
if (request === "spawn descendant") {
const descendant = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });
if (process.env.FAKE_ACP_DESCENDANT_PID_FILE) fs.writeFileSync(process.env.FAKE_ACP_DESCENDANT_PID_FILE, `${descendant.pid}\n`);
await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
return { stopReason: "cancelled" };
}
if (request === "hang") {
await new Promise((resolve) => {
const done = () => resolve(undefined);
pending.set(params.sessionId, done);
@@ -58,17 +136,14 @@ const app = acp.agent({ name: "fake-acp-agent" })
pending.delete(params.sessionId);
return { stopReason: "cancelled" };
}
if (text === "activity") {
if (request === "activity") {
await update(client, params.sessionId, "first");
await new Promise((resolve) => setTimeout(resolve, 600));
await update(client, params.sessionId, "second");
await new Promise((resolve) => setTimeout(resolve, 700));
return { stopReason: "end_turn" };
}
if (text.includes("Initialize this ACP session") && process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) {
await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
}
await update(client, params.sessionId, text.includes("Initialize this ACP session") ? "READY" : `reply:${params.sessionId}:${text}`);
await update(client, params.sessionId, `reply:${params.sessionId}:${request}`);
return { stopReason: "end_turn" };
})
.onNotification(acp.methods.agent.session.cancel, ({ params }) => {