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:
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
|
||||
@@ -51,3 +54,37 @@ test("reports activity for every update from the active session", async () => {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
test("prompt sends text and image content blocks and records the image capability", async () => {
|
||||
const logFile = path.join(os.tmpdir(), `gori-acp-client-${crypto.randomUUID()}.log`);
|
||||
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
||||
const child = spawn(process.execPath, [fixture], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, FAKE_ACP_LOG: logFile }
|
||||
});
|
||||
const client = new AcpClient(child, {
|
||||
initializeTimeoutMs: 1_000,
|
||||
policy: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
|
||||
});
|
||||
try {
|
||||
await client.initialize();
|
||||
assert.equal(client.supportsImageInput(), true);
|
||||
await client.newSession(path.resolve("."));
|
||||
const image = Buffer.from("fake-png").toString("base64");
|
||||
const reply = await client.prompt([
|
||||
{ type: "image", data: image, mimeType: "image/png" },
|
||||
{ type: "text", text: "hello blocks" }
|
||||
]);
|
||||
assert.match(reply, /^reply:.+:hello blocks$/);
|
||||
const prompts = fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as { method: string; text?: string; images?: number })
|
||||
.filter((entry) => entry.method === "session/prompt");
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.equal(prompts[0]!.text, "hello blocks");
|
||||
assert.equal(prompts[0]!.images, 1);
|
||||
} finally {
|
||||
client.close();
|
||||
child.kill("SIGTERM");
|
||||
await fs.promises.rm(logFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
+283
-121
@@ -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"; }
|
||||
|
||||
Vendored
+21
-14
@@ -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" };
|
||||
}
|
||||
|
||||
+41
-4
@@ -31,10 +31,12 @@ class FakeRuntime implements ConversationRuntime {
|
||||
readonly turns: PendingTurn[] = [];
|
||||
cancelled = 0;
|
||||
confirmed = 0;
|
||||
finished = 0;
|
||||
stopped = 0;
|
||||
listed = 0;
|
||||
cancelResult = true;
|
||||
confirmResult = true;
|
||||
finishResult = true;
|
||||
stopResult = true;
|
||||
proposals: Proposal[] = [];
|
||||
commandUsers: Record<string, string | undefined> = {};
|
||||
@@ -50,6 +52,7 @@ class FakeRuntime implements ConversationRuntime {
|
||||
}
|
||||
async cancel(_platform: string, _chatId: string, userId: string) { this.cancelled++; this.commandUsers.cancel = userId; return this.cancelResult; }
|
||||
async confirm(_platform: string, _chatId: string, userId: string) { this.confirmed++; this.commandUsers.confirm = userId; return this.confirmResult; }
|
||||
async finish(_platform: string, _chatId: string, userId: string) { this.finished++; this.commandUsers.finish = userId; return this.finishResult; }
|
||||
async stop(_platform: string, _chatId: string, userId: string) { this.stopped++; this.commandUsers.stop = userId; return this.stopResult; }
|
||||
listProposals(_platform: string, _chatId: string, userId: string) { this.listed++; this.commandUsers.list = userId; return this.proposals; }
|
||||
async reset() {}
|
||||
@@ -434,20 +437,22 @@ test("status and help reach the runtime or reply directly", async () => {
|
||||
const help = await gateway.receive(message("/help"), adapter, { synchronous: true });
|
||||
assert.match(help.reply || "", /自然语言/);
|
||||
assert.match(help.reply || "", /\/confirm/);
|
||||
assert.match(help.reply || "", /\/finish/);
|
||||
assert.match(help.reply || "", /\/stop/);
|
||||
assert.match(help.reply || "", /\/cancel/);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
});
|
||||
|
||||
test("list, confirm, stop, and cancel forward to the runtime with the chat identity", async () => {
|
||||
test("list, confirm, finish, stop, and cancel forward to the runtime with the chat identity", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const adapter = recordingAdapter();
|
||||
runtime.proposals = [fakeProposal()];
|
||||
|
||||
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.listed, 1);
|
||||
assert.match(list.reply || "", /proposal-1 \[proposed\] 修复登录页/);
|
||||
assert.match(list.reply || "", /未确认(proposed)/);
|
||||
assert.match(list.reply || "", /proposal-1「修复登录页」/);
|
||||
|
||||
runtime.proposals = [];
|
||||
const emptyList = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
||||
@@ -455,11 +460,15 @@ test("list, confirm, stop, and cancel forward to the runtime with the chat ident
|
||||
|
||||
const confirm = await gateway.receive(message("/confirm"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.confirmed, 1);
|
||||
assert.equal(confirm.reply, "已确认,继续执行。");
|
||||
assert.equal(confirm.reply, "已确认,加入队列。");
|
||||
|
||||
const finish = await gateway.receive(message("/finish"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.finished, 1);
|
||||
assert.equal(finish.reply, "已结束该任务。");
|
||||
|
||||
const stop = await gateway.receive(message("/stop"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.stopped, 1);
|
||||
assert.equal(stop.reply, "已停止当前任务。");
|
||||
assert.equal(stop.reply, "已停止当前任务,任务转为待确认。");
|
||||
|
||||
const cancel = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.cancelled, 1);
|
||||
@@ -467,17 +476,45 @@ test("list, confirm, stop, and cancel forward to the runtime with the chat ident
|
||||
|
||||
assert.equal(runtime.commandUsers.list, "user");
|
||||
assert.equal(runtime.commandUsers.confirm, "user");
|
||||
assert.equal(runtime.commandUsers.finish, "user");
|
||||
assert.equal(runtime.commandUsers.stop, "user");
|
||||
assert.equal(runtime.commandUsers.cancel, "user");
|
||||
|
||||
runtime.confirmResult = false;
|
||||
runtime.finishResult = false;
|
||||
runtime.stopResult = false;
|
||||
runtime.cancelResult = false;
|
||||
assert.equal((await gateway.receive(message("/confirm"), adapter, { synchronous: true })).reply, "当前没有待确认的提案。");
|
||||
assert.equal((await gateway.receive(message("/finish"), adapter, { synchronous: true })).reply, "当前没有待结束的任务。");
|
||||
assert.equal((await gateway.receive(message("/stop"), adapter, { synchronous: true })).reply, "当前没有正在执行的任务。");
|
||||
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "没有可取消的提案。");
|
||||
});
|
||||
|
||||
test("the list panel orders pending first, then working, queued, proposed, and recent finished", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const adapter = recordingAdapter();
|
||||
runtime.proposals = [
|
||||
fakeProposal({ id: "p-finished", title: "旧任务", status: "finished", finishKind: "done", finishedAt: 10 }),
|
||||
fakeProposal({ id: "p-proposed", title: "新想法", status: "proposed" }),
|
||||
fakeProposal({ id: "p-working", title: "干活中", status: "working" }),
|
||||
fakeProposal({ id: "p-queued", title: "排队任务", status: "queued" }),
|
||||
fakeProposal({ id: "p-pending", title: "待确认任务", status: "pending", pending: { summary: "做了一半", question: "继续吗?", receivedAt: 5 } }),
|
||||
fakeProposal({ id: "p-cancelled", title: "取消任务", status: "finished", finishKind: "cancelled", finishedAt: 20 })
|
||||
];
|
||||
|
||||
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
||||
const reply = list.reply || "";
|
||||
const order = ["待确认(等你处理)", "进行中", "排队中", "未确认(proposed)", "最近结束"]
|
||||
.map((section) => reply.indexOf(section));
|
||||
assert.ok(order.every((index) => index >= 0), reply);
|
||||
assert.deepEqual([...order].sort((a, b) => a - b), order);
|
||||
assert.match(reply, /p-pending「待确认任务」:做了一半(问你:继续吗?)/);
|
||||
assert.match(reply, /\/finish 结束/);
|
||||
assert.match(reply, /p-working「干活中」正在执行/);
|
||||
assert.match(reply, /p-cancelled「取消任务」已取消/);
|
||||
assert.match(reply, /p-finished「旧任务」已完成/);
|
||||
});
|
||||
|
||||
test("synchronous work sends no delivery messages", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
|
||||
+129
-12
@@ -21,30 +21,39 @@ test("creates, updates and reloads proposals with 0600 atomic file", async () =>
|
||||
|
||||
const confirmedAt = created.createdAt + 1000;
|
||||
const updated = await first.update(created.id, {
|
||||
status: "working",
|
||||
status: "pending",
|
||||
confirmedAt,
|
||||
startedAt: confirmedAt + 1,
|
||||
workerNativeSessionId: "native-1",
|
||||
workerProcessGroup: { pgid: 4321, token: "worker-token" },
|
||||
pending: { kind: "step", summary: "needs dirty confirm", question: "overwrite?", nextStep: "edit file" },
|
||||
pending: { summary: "needs dirty confirm", question: "overwrite?", workspaceDirty: true, receivedAt: confirmedAt + 2 },
|
||||
lastWorkerSummary: "half done"
|
||||
});
|
||||
assert.equal(updated.status, "working");
|
||||
assert.equal(updated.status, "pending");
|
||||
assert.ok(updated.updatedAt >= created.updatedAt);
|
||||
|
||||
const finished = await first.update(created.id, {
|
||||
status: "finished",
|
||||
finishKind: "done",
|
||||
finishNote: "wrapped up",
|
||||
pending: undefined,
|
||||
finishedAt: confirmedAt + 3
|
||||
});
|
||||
assert.equal(finished.finishKind, "done");
|
||||
await first.close();
|
||||
|
||||
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
|
||||
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
|
||||
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
|
||||
assert.equal(persisted.version, 1); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
|
||||
assert.equal(persisted.version, 2); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
|
||||
assert.equal(persisted.proposals[created.id].workerProcessGroup.pgid, 4321);
|
||||
|
||||
const second = new ProposalStore(file, identity);
|
||||
await second.open();
|
||||
const loaded = second.get(created.id);
|
||||
assert.deepEqual(loaded, updated);
|
||||
assert.deepEqual(loaded, finished);
|
||||
assert.deepEqual(second.list().map((proposal) => proposal.id), [created.id]);
|
||||
assert.deepEqual(second.list({ status: "working" }).map((proposal) => proposal.id), [created.id]);
|
||||
assert.deepEqual(second.list({ status: "finished" }).map((proposal) => proposal.id), [created.id]);
|
||||
assert.equal(second.list({ status: "queued" }).length, 0);
|
||||
assert.equal(second.stats().proposals, 1);
|
||||
await second.close();
|
||||
@@ -73,7 +82,9 @@ test("validates proposal fields on create and update", async () => {
|
||||
const created = await store.create(input);
|
||||
await assert.rejects(store.update(created.id, { status: "bogus" as never }), /status/);
|
||||
await assert.rejects(store.update(created.id, { workerProcessGroup: { pgid: 1, token: "t" } }), /workerProcessGroup/);
|
||||
await assert.rejects(store.update(created.id, { pending: { kind: "bogus", summary: "s" } as never }), /pending/);
|
||||
await assert.rejects(store.update(created.id, { pending: { summary: "s" } as never }), /pending/);
|
||||
await assert.rejects(store.update(created.id, { pending: { summary: "s", receivedAt: "now" } as never }), /pending/);
|
||||
await assert.rejects(store.update(created.id, { finishKind: "bogus" as never }), /finishKind/);
|
||||
await assert.rejects(store.update("missing", { status: "queued" }), /unknown proposal/);
|
||||
assert.equal(store.get(created.id)?.status, "proposed");
|
||||
await store.close();
|
||||
@@ -82,24 +93,130 @@ test("validates proposal fields on create and update", async () => {
|
||||
test("rejects identity or version mismatch and preserves corrupt state", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-id-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const wrongVersion = '{"version":2,"botId":"test-bot","platform":"qq","proposals":{}}\n';
|
||||
const wrongVersion = '{"version":3,"botId":"test-bot","platform":"qq","proposals":{}}\n';
|
||||
await fs.promises.writeFile(file, wrongVersion);
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /expected version 1/);
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /expected version 2/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), wrongVersion);
|
||||
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "other", platform: "qq", proposals: {} }));
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", proposals: {} }));
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "feishu", proposals: {} }));
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", proposals: {} }));
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
|
||||
|
||||
await fs.promises.writeFile(file, "not-json");
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /original file was preserved/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
|
||||
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "qq", proposals: { abc: { id: "other" } } }));
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", proposals: { abc: { id: "other" } } }));
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /invalid proposal/);
|
||||
});
|
||||
|
||||
function v1Proposal(id: string, status: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
title: `title-${id}`,
|
||||
goal: "goal",
|
||||
steps: ["step"],
|
||||
ownerChatKey: "qq:chat",
|
||||
requesterUserId: "user-1",
|
||||
status,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
test("migrates a v1 store: backs up the original file and maps every legacy status", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-migrate-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const v1 = {
|
||||
version: 1,
|
||||
botId: "test-bot",
|
||||
platform: "qq",
|
||||
proposals: {
|
||||
success: v1Proposal("success", "awaiting_user_confirmation", { pending: { kind: "success", summary: "all done" } }),
|
||||
failure: v1Proposal("failure", "awaiting_user_confirmation", { pending: { kind: "failure", summary: "it broke" } }),
|
||||
step: v1Proposal("step", "awaiting_user_confirmation", { pending: { kind: "step", summary: "dirty target", question: "overwrite?", nextStep: "edit" } }),
|
||||
done: v1Proposal("done", "completed", { finishedAt: 10 }),
|
||||
broken: v1Proposal("broken", "failed", { finishedAt: 11, lastWorkerSummary: "worker blew up" }),
|
||||
dropped: v1Proposal("dropped", "cancelled", { finishedAt: 12 }),
|
||||
fresh: v1Proposal("fresh", "proposed"),
|
||||
lined: v1Proposal("lined", "queued", { confirmedAt: 5 }),
|
||||
busy: v1Proposal("busy", "working", { startedAt: 6, workerNativeSessionId: "native-9", workerProcessGroup: { pgid: 4321, token: "tok" } })
|
||||
}
|
||||
};
|
||||
const raw = `${JSON.stringify(v1, null, 2)}\n`;
|
||||
await fs.promises.writeFile(file, raw, { mode: 0o600 });
|
||||
|
||||
const store = new ProposalStore(file, identity);
|
||||
await store.open();
|
||||
|
||||
const backups = (await fs.promises.readdir(dir)).filter((name) => name.startsWith("proposals.json.v1-") && name.endsWith(".bak"));
|
||||
assert.equal(backups.length, 1);
|
||||
assert.equal((await fs.promises.stat(path.join(dir, backups[0]!))).mode & 0o777, 0o600);
|
||||
assert.equal(await fs.promises.readFile(path.join(dir, backups[0]!), "utf8"), raw);
|
||||
|
||||
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
|
||||
assert.equal(persisted.version, 2);
|
||||
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
|
||||
|
||||
const success = store.get("success")!;
|
||||
assert.equal(success.status, "pending");
|
||||
assert.deepEqual(success.pending, { summary: "all done", receivedAt: 2 });
|
||||
assert.equal(success.pending?.workspaceDirty, undefined);
|
||||
|
||||
const failure = store.get("failure")!;
|
||||
assert.equal(failure.status, "pending");
|
||||
assert.equal(failure.pending?.summary, "it broke");
|
||||
assert.equal(failure.pending?.workspaceDirty, true);
|
||||
|
||||
const step = store.get("step")!;
|
||||
assert.equal(step.status, "pending");
|
||||
assert.equal(step.pending?.summary, "dirty target");
|
||||
assert.equal(step.pending?.question, "overwrite?");
|
||||
assert.equal((step.pending as unknown as Record<string, unknown>).nextStep, undefined);
|
||||
|
||||
const done = store.get("done")!;
|
||||
assert.equal(done.status, "finished");
|
||||
assert.equal(done.finishKind, "done");
|
||||
assert.equal(done.finishedAt, 10);
|
||||
|
||||
const broken = store.get("broken")!;
|
||||
assert.equal(broken.status, "finished");
|
||||
assert.equal(broken.finishKind, "done");
|
||||
assert.equal(broken.finishNote, "worker blew up");
|
||||
|
||||
const dropped = store.get("dropped")!;
|
||||
assert.equal(dropped.status, "finished");
|
||||
assert.equal(dropped.finishKind, "cancelled");
|
||||
|
||||
assert.equal(store.get("fresh")!.status, "proposed");
|
||||
assert.equal(store.get("lined")!.status, "queued");
|
||||
const busy = store.get("busy")!;
|
||||
assert.equal(busy.status, "working");
|
||||
assert.equal(busy.workerNativeSessionId, "native-9");
|
||||
assert.deepEqual(busy.workerProcessGroup, { pgid: 4321, token: "tok" });
|
||||
await store.close();
|
||||
|
||||
// The migrated store reloads as a plain v2 file without creating another backup.
|
||||
const reloaded = new ProposalStore(file, identity);
|
||||
await reloaded.open();
|
||||
assert.equal(reloaded.stats().proposals, 9);
|
||||
await reloaded.close();
|
||||
const backupsAfter = (await fs.promises.readdir(dir)).filter((name) => name.endsWith(".bak"));
|
||||
assert.equal(backupsAfter.length, 1);
|
||||
});
|
||||
|
||||
test("a v1 store with a mismatched identity is rejected before any backup is written", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-migrate-id-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const raw = `${JSON.stringify({ version: 1, botId: "other-bot", platform: "qq", proposals: {} })}\n`;
|
||||
await fs.promises.writeFile(file, raw, { mode: 0o600 });
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), raw);
|
||||
assert.equal((await fs.promises.readdir(dir)).filter((name) => name.endsWith(".bak")).length, 0);
|
||||
});
|
||||
|
||||
test("refuses a second writer and recovers a stale lock", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-lock-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
|
||||
@@ -31,6 +31,125 @@ test("group messages without member_openid fall back to user_openid", async () =
|
||||
assert.equal(received[1].userId, "mem4");
|
||||
});
|
||||
|
||||
test("image attachments are downloaded to base64 while video and file attachments degrade to text links", async () => {
|
||||
const received: any[] = [];
|
||||
const gateway = { receive: async (message: unknown) => { received.push(message); } };
|
||||
const adapter = new QqAdapter(config, gateway as never);
|
||||
const pngBytes = Buffer.from("fake-png-bytes");
|
||||
const urls: string[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
urls.push(String(input));
|
||||
return new Response(pngBytes, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await adapter.handleWebhook({
|
||||
body: {
|
||||
op: 0, t: "C2C_MESSAGE_CREATE",
|
||||
d: {
|
||||
id: "m10", author: { user_openid: "u10" }, content: "看看这些",
|
||||
attachments: [
|
||||
{ content_type: "image/png", filename: "a.png", size: pngBytes.length, url: "//cdn.example.com/a.png" },
|
||||
{ content_type: "video/mp4", filename: "v.mp4", size: 1024, url: "https://cdn.example.com/v.mp4" },
|
||||
{ content_type: "application/pdf", filename: "f.pdf", size: 1024, url: "https://cdn.example.com/f.pdf" }
|
||||
]
|
||||
}
|
||||
},
|
||||
headers: {}, query: {}, req: {} as never
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(received.length, 1);
|
||||
const message = received[0];
|
||||
assert.deepEqual(urls, ["https://cdn.example.com/a.png"]);
|
||||
assert.equal(message.attachments.length, 1);
|
||||
assert.equal(message.attachments[0].mimeType, "image/png");
|
||||
assert.equal(message.attachments[0].filename, "a.png");
|
||||
assert.equal(message.attachments[0].data, pngBytes.toString("base64"));
|
||||
assert.match(message.text, /^看看这些/);
|
||||
assert.match(message.text, /\[视频\] https:\/\/cdn\.example\.com\/v\.mp4/);
|
||||
assert.match(message.text, /\[文件\] https:\/\/cdn\.example\.com\/f\.pdf/);
|
||||
} finally { globalThis.fetch = original; }
|
||||
});
|
||||
|
||||
test("oversized images are skipped and images beyond the per-message cap are not downloaded", async () => {
|
||||
const received: any[] = [];
|
||||
const gateway = { receive: async (message: unknown) => { received.push(message); } };
|
||||
const adapter = new QqAdapter(config, gateway as never);
|
||||
const urls: string[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
urls.push(String(input));
|
||||
return new Response(Buffer.from("x"), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await adapter.handleWebhook({
|
||||
body: {
|
||||
op: 0, t: "C2C_MESSAGE_CREATE",
|
||||
d: {
|
||||
id: "m11", author: { user_openid: "u11" }, content: "hi",
|
||||
attachments: [
|
||||
{ content_type: "image/png", size: 6 * 1024 * 1024, url: "https://cdn.example.com/big.png" },
|
||||
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/1.png" },
|
||||
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/2.png" },
|
||||
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/3.png" },
|
||||
{ content_type: "image/png", size: 10, url: "https://cdn.example.com/4.png" }
|
||||
]
|
||||
}
|
||||
},
|
||||
headers: {}, query: {}, req: {} as never
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(received.length, 1);
|
||||
assert.deepEqual(urls, [
|
||||
"https://cdn.example.com/1.png",
|
||||
"https://cdn.example.com/2.png",
|
||||
"https://cdn.example.com/3.png"
|
||||
]);
|
||||
assert.equal(received[0].attachments.length, 3);
|
||||
} finally { globalThis.fetch = original; }
|
||||
});
|
||||
|
||||
test("a pure image message uses placeholder text and failed downloads degrade", async () => {
|
||||
const received: any[] = [];
|
||||
const gateway = { receive: async (message: unknown) => { received.push(message); } };
|
||||
const adapter = new QqAdapter(config, gateway as never);
|
||||
const original = globalThis.fetch;
|
||||
let fail = false;
|
||||
globalThis.fetch = (async () => {
|
||||
if (fail) throw new Error("network down");
|
||||
return new Response(Buffer.from("img"), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await adapter.handleWebhook({
|
||||
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m12", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/a.jpg" }] } },
|
||||
headers: {}, query: {}, req: {} as never
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0].text, "(发来一张图片)");
|
||||
assert.equal(received[0].attachments.length, 1);
|
||||
|
||||
// Every download failing with no text keeps the message ignored.
|
||||
fail = true;
|
||||
await adapter.handleWebhook({
|
||||
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m13", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/b.jpg" }] } },
|
||||
headers: {}, query: {}, req: {} as never
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(received.length, 1);
|
||||
|
||||
// Failed downloads with text still deliver the text without attachments.
|
||||
await adapter.handleWebhook({
|
||||
body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m14", author: { user_openid: "u12" }, content: "看图", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/c.jpg" }] } },
|
||||
headers: {}, query: {}, req: {} as never
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(received.length, 2);
|
||||
assert.equal(received[1].text, "看图");
|
||||
assert.equal(received[1].attachments, undefined);
|
||||
} finally { globalThis.fetch = original; }
|
||||
});
|
||||
|
||||
test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", async () => {
|
||||
const original = globalThis.fetch; const urls: string[] = []; const bodies: Array<Record<string, unknown>> = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
|
||||
Reference in New Issue
Block a user