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
+283 -121
View File
@@ -9,6 +9,7 @@ import { AssistantManager, assistantKeyFor, parseAssistantActions, parseWorkerRe
import { parseConfig } from "../src/config.js";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
import { ProposalStore } from "../src/core/proposal-store.js";
import type { IncomingAttachment } from "../src/core/types.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
@@ -81,7 +82,8 @@ async function closeHarness(harness: Harness): Promise<void> {
await fs.promises.rm(harness.workspace, { recursive: true, force: true });
}
const request = (text: string, userId = "user-1") => ({ platform: "webhook", chatId: "chat-1", userId, text });
const request = (text: string, userId = "user-1", attachments?: IncomingAttachment[]) => ({ platform: "webhook", chatId: "chat-1", userId, text, attachments });
const pngAttachment = (): IncomingAttachment[] => [{ mimeType: "image/png", data: Buffer.from("fake-png").toString("base64"), filename: "fake.png" }];
async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
@@ -91,7 +93,7 @@ async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<voi
}
}
function readLog(logFile: string): { method: string; sessionId?: string; cwd?: string; text?: string }[] {
function readLog(logFile: string): { method: string; sessionId?: string; cwd?: string; text?: string; images?: number }[] {
if (!fs.existsSync(logFile)) return [];
return fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as { method: string });
}
@@ -118,9 +120,10 @@ test("create proposal stays proposed until confirm; confirm starts the worker wi
await harness.manager.prompt(request("stop"));
const stopped = harness.proposals.get(proposal.id)!;
assert.equal(stopped.status, "cancelled");
assert.equal(stopped.status, "pending");
assert.equal(stopped.pending?.summary, "被用户中止");
assert.equal(stopped.pending?.workspaceDirty, true);
assert.equal(stopped.workerProcessGroup, undefined);
assert.ok(stopped.finishedAt);
} finally {
await closeHarness(harness);
}
@@ -159,98 +162,162 @@ test("stop does not deadlock while a worker result is waiting to settle", async
assert.ok(startSettle);
void startSettle();
assert.equal(await stopped, true);
await waitFor(() => harness.proposals.get(proposal.id)!.status === "cancelled");
assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled");
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止");
} finally {
await fs.promises.rm(gateFile, { force: true });
await closeHarness(harness);
}
});
test("SUCCESS waits for user confirmation and never auto-completes or auto-starts the next proposal", async () => {
test("pending never auto-finishes or auto-starts the next proposal; only finish settles it", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success");
await waitFor(() => harness.proposals.get(first.id)!.status === "pending");
assert.equal(harness.proposals.get(first.id)!.pending?.summary, "goal done");
assert.equal(harness.proposals.get(first.id)!.finishedAt, undefined);
assert.equal(harness.proposals.get(first.id)!.finishKind, undefined);
// A new proposal can be queued while the owner has a pending one, but it must not start.
await harness.manager.prompt(request("create proposal: succeed"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.status, "pending");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(first.id)!.status, "completed");
assert.ok(harness.proposals.get(first.id)!.finishedAt);
await harness.manager.prompt(request("finish"));
const finishedFirst = harness.proposals.get(first.id)!;
assert.equal(finishedFirst.status, "finished");
assert.equal(finishedFirst.finishKind, "done");
assert.ok(finishedFirst.finishedAt);
assert.equal(finishedFirst.pending, undefined);
assert.equal(finishedFirst.lastWorkerSummary, "goal done");
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("start next"));
assert.equal(harness.proposals.get(second.id)!.status, "working");
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "completed");
await waitFor(() => harness.proposals.get(second.id)!.status === "pending");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-1"), true);
assert.equal(harness.proposals.get(second.id)!.status, "finished");
} finally {
await closeHarness(harness);
}
});
test("FAILED requires confirmation; confirm ends it and confirm retry restarts a working worker", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: fail"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "failure");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(first.id)!.status, "failed");
assert.ok(harness.proposals.get(first.id)!.finishedAt);
await harness.manager.prompt(request("create proposal: fail"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
await harness.manager.prompt(request("confirm retry"));
assert.equal(harness.proposals.get(second.id)!.status, "working");
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(second.id)!.pending?.kind, "failure");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "failed");
} finally {
await closeHarness(harness);
}
});
test("NEEDS_CONFIRMATION answer continues the same worker session", async () => {
test("follow_up on a pending proposal resumes the same worker session and carries image attachments", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: ask"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
const pendingStep = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pendingStep.kind, "step");
assert.equal(pendingStep.question, "May I overwrite it?");
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const pendingCard = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pendingCard.question, "May I overwrite it?");
assert.equal(pendingCard.workspaceDirty, true);
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
assert.ok(workerSessionId);
await harness.manager.prompt(request("answer: yes, overwrite it"));
await waitFor(() => harness.proposals.get(proposal.id)!.pending?.kind === "success");
await harness.manager.prompt(request("follow up: yes, overwrite it", "user-1", pngAttachment()));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const continued = harness.proposals.get(proposal.id)!;
assert.equal(continued.workerNativeSessionId, workerSessionId);
assert.match(continued.lastWorkerSummary || "", new RegExp(`continued ${workerSessionId}`));
assert.equal(continued.pending?.question, undefined);
assert.equal(continued.pending?.workspaceDirty, undefined);
const workerPrompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text && (entry.text.startsWith("Execute this confirmed proposal") || entry.text.startsWith("The user answered your question")));
assert.equal(workerPrompts.length, 2);
assert.ok(workerPrompts.every((entry) => entry.sessionId === workerSessionId));
const resumeEntries = readLog(harness.logFile).filter((entry) => entry.method === "session/resume" && entry.sessionId === workerSessionId);
assert.ok(resumeEntries.length > 0);
const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("The user sent a follow-up instruction"));
assert.equal(followUpTurns.length, 1);
assert.equal(followUpTurns[0]!.sessionId, workerSessionId);
assert.equal(followUpTurns[0]!.images, 1);
await harness.manager.prompt(request("finish"));
assert.equal(harness.proposals.get(proposal.id)!.status, "finished");
assert.equal(harness.proposals.get(proposal.id)!.finishKind, "done");
} finally {
await closeHarness(harness);
}
});
test("follow_up falls back to a fresh worker session with proposal context when resume fails", async () => {
const harness = await createHarness({ agentEnv: { FAKE_ACP_RESUME_FAIL: "1", FAKE_ACP_LOAD_FAIL: "1" } });
try {
await harness.manager.prompt(request("create proposal: ask"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
await harness.manager.prompt(request("follow up: yes, overwrite it"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const continued = harness.proposals.get(proposal.id)!;
assert.ok(continued.workerNativeSessionId);
assert.notEqual(continued.workerNativeSessionId, workerSessionId);
assert.match(continued.lastWorkerSummary || "", /continued/);
const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("The user sent a follow-up instruction"));
assert.equal(followUpTurns.length, 1);
assert.match(followUpTurns[0]!.text || "", /fresh session continuing the same proposal/);
} finally {
await closeHarness(harness);
}
});
test("a fresh non-finish message reminds about the pending proposal when the assistant forgets it", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const forgotten = await harness.manager.prompt(request("hello"));
assert.match(forgotten.text, /待确认的任务「Test proposal」/);
assert.match(forgotten.text, /finish 结束/);
const acknowledged = await harness.manager.prompt(request("ack pending"));
assert.match(acknowledged.text, /任务「Test proposal」还在等你确认/);
assert.doesNotMatch(acknowledged.text, /提醒:你还有/);
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.match(prompts[0]!.text!, /\[Pending proposals\]/);
assert.match(prompts[0]!.text!, /summary="goal done"/);
} finally {
await closeHarness(harness);
}
});
test("adjust_proposal edits proposed or queued proposals but not pending ones", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("adjust proposal: Better title"));
assert.equal(harness.proposals.get(first.id)!.title, "Better title");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(proposal.id)!.status, "completed");
await waitFor(() => harness.proposals.get(first.id)!.status === "pending");
await harness.manager.prompt(request("create proposal: succeed"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("adjust proposal: Queued title"));
assert.equal(harness.proposals.get(second.id)!.title, "Queued title");
// With nothing proposed or queued left, the pending proposal cannot be adjusted.
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-1"), true);
assert.equal(harness.proposals.get(second.id)!.status, "finished");
const rejected = await harness.manager.prompt(request("adjust proposal: Nope"));
assert.match(rejected.text, /没有可调整的提案/);
assert.equal(harness.proposals.get(first.id)!.title, "Better title");
} finally {
await closeHarness(harness);
}
@@ -275,7 +342,7 @@ test("worker completion notifies the owner chat through the event sink", async (
await harness.manager.prompt(request("create proposal: succeed"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
await waitFor(() => harness.events.length > 0);
assert.equal(harness.events[0]!.chatKey, CHAT_KEY);
assert.match(harness.events[0]!.text, /event received: worker update/);
@@ -284,7 +351,7 @@ test("worker completion notifies the owner chat through the event sink", async (
}
});
test("restart recovery kills the persisted worker group and marks the working proposal failed as worker_lost", async () => {
test("restart recovery kills the persisted worker group and marks the working proposal pending as worker_lost", async () => {
const harness = await createHarness();
const token = crypto.randomUUID();
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
@@ -301,10 +368,10 @@ test("restart recovery kills the persisted worker group and marks the working pr
await reopenManager(harness);
const recovered = harness.proposals.get(proposal.id)!;
assert.equal(recovered.status, "failed");
assert.equal(recovered.status, "pending");
assert.match(recovered.pending?.summary || "", /worker_lost/);
assert.equal(recovered.pending?.workspaceDirty, true);
assert.match(recovered.lastWorkerSummary || "", /worker_lost/);
assert.ok(recovered.finishedAt);
await waitFor(() => !processGroupExists(child.pid!));
} finally {
try { process.kill(-child.pid!, "SIGKILL"); } catch { /* already dead */ }
@@ -312,7 +379,7 @@ test("restart recovery kills the persisted worker group and marks the working pr
}
});
test("proposals are owned per chat+user: a second user in the same chat cannot confirm, stop, cancel, or list them", async () => {
test("proposals are owned per chat+user: a second user in the same chat cannot confirm, finish, stop, cancel, or list them", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
@@ -328,6 +395,7 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-b"), false);
await harness.manager.prompt(request("cancel proposal", "user-b"));
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), false);
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 0);
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").length, 1);
@@ -344,7 +412,8 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled");
assert.equal(harness.proposals.get(proposal.id)!.status, "pending");
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止");
} finally {
await closeHarness(harness);
}
@@ -356,68 +425,47 @@ test("start_next only starts the requesting user's own queued proposal", async (
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
await waitFor(() => harness.proposals.get(first.id)!.status === "pending");
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm", "user-a"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("confirm", "user-a"));
assert.equal(harness.proposals.get(first.id)!.status, "completed");
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(first.id)!.status, "finished");
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("start next", "user-b"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("start next", "user-a"));
assert.equal(harness.proposals.get(second.id)!.status, "working");
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
await waitFor(() => harness.proposals.get(second.id)!.status === "pending");
} finally {
await closeHarness(harness);
}
});
test("stop settles the latest awaiting proposal (success completes, failure fails, step cancels) without auto-starting the next", async () => {
test("cancel settles proposed, queued, or pending proposals as cancelled and keeps finished history", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success");
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(first.id)!.status, "finished");
assert.equal(harness.proposals.get(first.id)!.finishKind, "cancelled");
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm", "user-a"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await waitFor(() => harness.proposals.get(second.id)!.status === "pending");
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true);
const cancelled = harness.proposals.get(second.id)!;
assert.equal(cancelled.status, "finished");
assert.equal(cancelled.finishKind, "cancelled");
assert.ok(cancelled.finishedAt);
await harness.manager.prompt(request("stop", "user-a"));
assert.equal(harness.proposals.get(first.id)!.status, "completed");
assert.ok(harness.proposals.get(first.id)!.finishedAt);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("start next", "user-a"));
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(second.id)!.status, "completed");
await harness.manager.prompt(request("create proposal: fail", "user-a"));
const third = harness.proposals.list().find((proposal) => proposal.id !== first.id && proposal.id !== second.id)!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(third.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(third.id)!.pending?.kind, "failure");
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(third.id)!.status, "failed");
await harness.manager.prompt(request("create proposal: ask", "user-a"));
const fourth = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(fourth.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(fourth.id)!.pending?.kind, "step");
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(fourth.id)!.status, "cancelled");
assert.equal(harness.manager.status("webhook", "chat-1", "user-a").workerRunning, false);
const finished = harness.manager.listProposals("webhook", "chat-1", "user-a");
assert.equal(finished.length, 2);
assert.ok(finished.every((proposal) => proposal.status === "finished"));
} finally {
await closeHarness(harness);
}
@@ -425,25 +473,112 @@ test("stop settles the latest awaiting proposal (success completes, failure fail
test("envelope parsers accept valid tails and reject invalid ones", () => {
assert.deepEqual(
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V1>`),
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "stop" }] }
);
assert.equal(parseAssistantActions("no envelope"), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"nuke"}]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V1>{"status":"NEEDS_CONFIRMATION","summary":"s","question":"q","dirty":true}</GORI_WORKER_RESULT_V1>`),
{ status: "NEEDS_CONFIRMATION", summary: "s", question: "q", nextStep: undefined, dirty: true }
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"follow_up","instruction":"keep going"},{"type":"finish","note":"done"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "follow_up", id: undefined, instruction: "keep going" }, { type: "finish", id: undefined, note: "done" }] }
);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"DONE","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
assert.equal(parseAssistantActions("no envelope"), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"nuke"}]}</GORI_ASSISTANT_ACTION_V2>`), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","question":"q","workspaceDirty":true}</GORI_WORKER_RESULT_V2>`),
{ status: "PENDING", summary: "s", question: "q", workspaceDirty: true }
);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"SUCCESS","summary":"s"}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"PENDING","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
});
test("start_next blocked by another user's pending confirmation appends a correction and exposes no details", async () => {
test("an owner's own pending proposal blocks their start_next with an actionable correction", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
await waitFor(() => harness.proposals.get(first.id)!.status === "pending");
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm", "user-a"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
const blocked = await harness.manager.prompt(request("start next", "user-a"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /先 finish 或直接继续说要求/);
assert.match(blocked.text, /保留在队列里/);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
const status = harness.manager.status("webhook", "chat-1", "user-a");
assert.equal(status.schedulerState, "pending");
assert.equal(status.blockedReason, "your proposal is waiting for your decision");
assert.equal(status.myPendingProposals, 1);
assert.equal(status.myQueuedProposals, 1);
assert.equal(status.nextAction, "finish your pending proposal or follow up with new instructions");
} finally {
await closeHarness(harness);
}
});
test("a clean pending proposal does not block other users, but a dirty one blocks everyone", async () => {
const harness = await createHarness();
try {
// user-a's clean pending: user-b's confirmed proposal starts immediately.
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const clean = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(clean.id)!.status === "pending");
assert.equal(harness.proposals.get(clean.id)!.pending?.workspaceDirty, undefined);
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const other = harness.proposals.list().find((proposal) => proposal.id !== clean.id)!;
await harness.manager.prompt(request("confirm", "user-b"));
assert.equal(harness.proposals.get(other.id)!.status, "working");
await waitFor(() => harness.proposals.get(other.id)!.status === "pending");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), true);
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(clean.id)!.status, "finished");
// user-a's dirty pending: user-b's start_next is globally blocked.
await harness.manager.prompt(request("create proposal: ask", "user-a"));
const dirty = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(dirty.id)!.status === "pending");
assert.equal(harness.proposals.get(dirty.id)!.pending?.workspaceDirty, true);
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const blockedProposal = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-b"));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued");
const blocked = await harness.manager.prompt(request("start next", "user-b"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /dirty 工作区/);
assert.doesNotMatch(blocked.text, new RegExp(dirty.title));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued");
const bStatus = harness.manager.status("webhook", "chat-1", "user-b");
assert.equal(bStatus.schedulerState, "pending");
assert.equal(bStatus.blockedReason, "a pending proposal left a dirty workspace");
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(dirty.id)!.status, "finished");
await harness.manager.prompt(request("start next", "user-b"));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "working");
await waitFor(() => harness.proposals.get(blockedProposal.id)!.status === "pending");
} finally {
await closeHarness(harness);
}
});
test("a working proposal blocks start_next for everyone with a sanitized reason", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(first.id)!.status === "working");
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
@@ -452,33 +587,27 @@ test("start_next blocked by another user's pending confirmation appends a correc
const blocked = await harness.manager.prompt(request("start next", "user-b"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /发起人确认/);
assert.match(blocked.text, /保留在队列里/);
assert.match(blocked.text, /正在执行/);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation");
const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nstart next"));
assert.ok(bPrompt?.text);
assert.match(bPrompt.text!, /blocked: another proposal is awaiting owner confirmation/);
assert.doesNotMatch(bPrompt.text!, /goal done/);
assert.match(bPrompt.text!, /busy: another proposal is working/);
const bStatus = harness.manager.status("webhook", "chat-1", "user-b");
assert.equal(bStatus.schedulerState, "awaiting_confirmation");
assert.equal(bStatus.blockedReason, "another proposal is awaiting owner confirmation");
assert.equal(bStatus.myQueuedProposals, 1);
assert.equal(bStatus.myPendingConfirmations, 0);
assert.equal(bStatus.schedulerState, "working");
assert.equal(bStatus.blockedReason, "another proposal is working");
assert.equal(bStatus.nextAction, "wait for the current proposal to settle");
const aStatus = harness.manager.status("webhook", "chat-1", "user-a");
assert.equal(aStatus.myPendingConfirmations, 1);
assert.equal(aStatus.blockedReason, "your proposal is awaiting your confirmation");
assert.equal(aStatus.nextAction, "confirm your pending proposal");
// follow_up is also globally blocked while a worker is running.
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(first.id)!.status, "pending");
} finally {
await closeHarness(harness);
}
});
test("confirm and stop actions with no matching proposal append an ownership correction", async () => {
test("confirm, finish, stop, cancel, and follow_up actions with no matching proposal append an ownership correction", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
@@ -487,6 +616,10 @@ test("confirm and stop actions with no matching proposal append an ownership cor
assert.match(confirmed.text, /没有可确认的提案/);
assert.match(confirmed.text, /自己发起/);
const finished = await harness.manager.prompt(request("finish", "user-b"));
assert.match(finished.text, /没有可结束的任务/);
assert.match(finished.text, /自己发起/);
const stopped = await harness.manager.prompt(request("stop", "user-b"));
assert.match(stopped.text, /没有可停止的任务/);
assert.match(stopped.text, /自己发起/);
@@ -495,6 +628,10 @@ test("confirm and stop actions with no matching proposal append an ownership cor
assert.match(cancelled.text, /没有可取消的提案/);
assert.match(cancelled.text, /自己发起/);
const followedUp = await harness.manager.prompt(request("follow up: more", "user-b"));
assert.match(followedUp.text, /现在没法继续/);
assert.match(followedUp.text, /自己发起/);
assert.equal(harness.proposals.list()[0]!.status, "proposed");
} finally {
await closeHarness(harness);
@@ -516,6 +653,31 @@ test("start_next reports when the queue is empty instead of claiming a start", a
}
});
test("image attachments reach the assistant as image blocks when the agent supports them", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("hello", "user-1", pngAttachment()));
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.images, 1);
} finally {
await closeHarness(harness);
}
});
test("image attachments degrade to a text note when the agent has no image capability", async () => {
const harness = await createHarness({ agentEnv: { FAKE_ACP_IMAGE_CAP: "0" } });
try {
await harness.manager.prompt(request("hello", "user-1", pngAttachment()));
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.images, 0);
assert.match(prompts[0]!.text!, /\[图片,当前 agent 不支持图片输入\]/);
} finally {
await closeHarness(harness);
}
});
function processGroupExists(pgid: number): boolean {
try { process.kill(-pgid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }