feat: pending-finish proposals and QQ image input

Proposal semantics: worker results no longer distinguish success/fail;
only an explicit finish settles a pending proposal. Pending owner input
follows up by resuming the original worker session. Dirty pending blocks
start_next globally; clean pending only blocks its owner.

QQ adapter now downloads image attachments and passes them as ACP image
content blocks; video/file attachments degrade to link text.
This commit is contained in:
zenord
2026-08-19 13:36:23 +08:00
parent 33854721bf
commit 7aa610294c
24 changed files with 1285 additions and 391 deletions
+21 -14
View File
@@ -7,15 +7,19 @@ 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 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 app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.initialize, ({ params }) => {
log({ method: "initialize" });
return {
protocolVersion: params.protocolVersion,
agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {}, close: {}, list: {} } },
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: process.env.FAKE_ACP_IMAGE_CAP !== "0" },
sessionCapabilities: { resume: {}, close: {}, list: {} }
},
agentInfo: { name: "fake-acp-agent", version: "1" }
};
})
@@ -39,7 +43,8 @@ const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.session.list, () => ({ sessions: [] }))
.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 });
const images = params.prompt.filter((item) => item.type === "image").length;
log({ method: "session/prompt", sessionId: params.sessionId, text, images });
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)));
@@ -48,14 +53,14 @@ const app = acp.agent({ name: "fake-acp-agent" })
}
// Assistant protocol
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1")) {
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V2")) {
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")) {
if (text.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V2")) {
await update(client, params.sessionId, process.env.FAKE_ACP_INVALID_REPAIR === "1"
? "still invalid"
: workerEnvelope({ status: "SUCCESS", summary: "repaired" }));
: workerEnvelope({ status: "PENDING", summary: "repaired" }));
return { stopReason: "end_turn" };
}
if (text.includes("[Internal event")) {
@@ -76,11 +81,13 @@ const app = acp.agent({ name: "fake-acp-agent" })
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 === "finish") response = assistantEnvelope("finishing", [{ type: "finish" }]);
else if (userText.startsWith("follow up:")) response = assistantEnvelope("following up", [{ type: "follow_up", instruction: userText.slice("follow up:".length).trim() }]);
else if (userText.startsWith("adjust proposal:")) response = assistantEnvelope("adjusting", [{ type: "adjust_proposal", title: userText.slice("adjust proposal:".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 === "ack pending") response = assistantEnvelope("任务「Test proposal」还在等你确认。", []);
else if (userText === "invalid assistant") response = "invalid without envelope";
else response = assistantEnvelope("ok", []);
await update(client, params.sessionId, response);
@@ -88,8 +95,8 @@ const app = acp.agent({ name: "fake-acp-agent" })
}
// Worker protocol
if (text.startsWith("The user answered your question")) {
await update(client, params.sessionId, workerEnvelope({ status: "SUCCESS", summary: `continued ${params.sessionId}` }));
if (text.startsWith("The user sent a follow-up instruction")) {
await update(client, params.sessionId, workerEnvelope({ status: "PENDING", summary: `continued ${params.sessionId}` }));
return { stopReason: "end_turn" };
}
if (text.startsWith("Execute this confirmed proposal")) {
@@ -111,10 +118,10 @@ const app = acp.agent({ name: "fake-acp-agent" })
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 }
? { status: "PENDING", summary: "hit a dirty target", question: "May I overwrite it?", workspaceDirty: true }
: goal.includes("fail")
? { status: "FAILED", summary: "something broke" }
: { status: "SUCCESS", summary: "goal done" };
? { status: "PENDING", summary: "something broke" }
: { status: "PENDING", summary: "goal done" };
await update(client, params.sessionId, workerEnvelope(result));
return { stopReason: "end_turn" };
}