import assert from "node:assert/strict"; import test from "node:test"; import type { ConversationRequest, ConversationRuntime } from "../src/acp/types.js"; import type { PlatformAdapter } from "../src/core/adapter.js"; import { Gateway } from "../src/core/gateway.js"; import type { Proposal } from "../src/core/proposal-store.js"; import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js"; interface PendingTurn { request: ConversationRequest; resolve(text: string): void; reject(error: Error): void; } function fakeProposal(overrides: Partial = {}): Proposal { return { id: "proposal-1", title: "修复登录页", goal: "goal", steps: ["step"], ownerChatKey: "qq:chat", requesterUserId: "user", status: "proposed", createdAt: 1, updatedAt: 1, ...overrides }; } class FakeRuntime implements ConversationRuntime { readonly turns: PendingTurn[] = []; cancelled = 0; confirmed = 0; stopped = 0; listed = 0; cancelResult = true; confirmResult = true; stopResult = true; proposals: Proposal[] = []; async prompt(request: ConversationRequest) { return new Promise<{ text: string; botId: string; agentId: string }>((resolve, reject) => { this.turns.push({ request, resolve: (text) => resolve({ text, botId: "test-bot", agentId: "kimi" }), reject }); }); } async cancel() { this.cancelled++; return this.cancelResult; } async confirm() { this.confirmed++; return this.confirmResult; } async stop() { this.stopped++; return this.stopResult; } listProposals() { this.listed++; return this.proposals; } async reset() {} status() { return { bot: "test-bot", agent: "kimi", workspace: "/tmp", running: this.turns.length > 0, phase: "processing" }; } stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; } async shutdown() {} } const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false }; const message = (text: string, messageId = text, chatId = "chat"): IncomingMessage => ({ platform: "qq", chatId, userId: "user", text, messageId }); const flush = async () => { await Promise.resolve(); await Promise.resolve(); await new Promise((resolve) => setImmediate(resolve)); }; const waitFor = async (condition: () => boolean) => { for (let count = 0; count < 50 && !condition(); count++) await flush(); assert.equal(condition(), true); }; function recordingAdapter(sent: OutgoingMessage[] = []): PlatformAdapter { return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } }; } function createGateway(runtime = new FakeRuntime()) { return { runtime, gateway: new Gateway(policy, runtime) }; } function messagesFor(sent: OutgoingMessage[], replyTo: string) { return sent.filter((outgoing) => outgoing.replyTo === replyTo); } test("short asynchronous work sends only the real result with sequence one", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; const turn = gateway.receive(message("work", "short"), recordingAdapter(sent)); await waitFor(() => runtime.turns.length === 1); runtime.turns[0].resolve("done"); assert.deepEqual(await turn, { ok: true, reply: "done" }); assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["done", 1]]); }); test("ordinary asynchronous messages send nothing before the runtime resolves", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; const turn = gateway.receive(message("work", "no-timer"), recordingAdapter(sent)); await waitFor(() => runtime.turns.length === 1); await flush(); assert.deepEqual(sent, []); runtime.turns[0].resolve("done"); await turn; assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]); }); test("ordinary messages reach the runtime immediately without a Gateway queue", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; const adapter = recordingAdapter(sent); const first = gateway.receive(message("one", "first"), adapter); const second = gateway.receive(message("two", "second"), adapter); await waitFor(() => runtime.turns.length === 2); assert.deepEqual(sent, []); runtime.turns[0].resolve("first done"); runtime.turns[1].resolve("second done"); await Promise.all([first, second]); assert.deepEqual(messagesFor(sent, "first").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["first done", 1]]); assert.deepEqual(messagesFor(sent, "second").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["second done", 1]]); }); test("delivery failures are logged safely and do not block the result", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; const errors: string[] = []; const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); throw new Error("sensitive provider response"); } }; const originalError = console.error; console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; try { const turn = gateway.receive(message("work", "failure"), adapter); await waitFor(() => runtime.turns.length === 1); runtime.turns[0].resolve("done"); assert.deepEqual(await turn, { ok: true, reply: "done" }); } finally { console.error = originalError; } assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]); assert.deepEqual(errors, ["Gateway delivery send failed (platform=qq)"]); }); test("runtime errors remain runtime errors and are delivered through the same stream", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; const turn = gateway.receive(message("work", "runtime-error"), recordingAdapter(sent)); await waitFor(() => runtime.turns.length === 1); runtime.turns[0].reject(new Error("failed")); assert.deepEqual(await turn, { ok: false, error: "failed", reply: "Agent error: failed" }); assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["Agent error: failed", 1]]); }); test("sendEvent pushes a fresh message to the latest chat target without replyTo", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; const turn = gateway.receive(message("work", "event-source"), recordingAdapter(sent)); await waitFor(() => runtime.turns.length === 1); runtime.turns[0].resolve("done"); await turn; await gateway.sendEvent("qq:chat", "任务完成了"); const event = sent.at(-1)!; assert.equal(event.text, "任务完成了"); assert.equal(event.replyTo, undefined); assert.equal(event.replySequence, undefined); assert.deepEqual(event.target, { platform: "qq", chatId: "chat", userId: "user", raw: undefined }); }); test("sendEvent uses the most recent target and adapter for the chat", async () => { const { runtime, gateway } = createGateway(); const firstSent: OutgoingMessage[] = []; const secondSent: OutgoingMessage[] = []; const first = gateway.receive(message("one", "one", "chat"), recordingAdapter(firstSent)); await waitFor(() => runtime.turns.length === 1); const second = gateway.receive(message("two", "two", "chat"), recordingAdapter(secondSent)); await waitFor(() => runtime.turns.length === 2); runtime.turns[0].resolve("one done"); runtime.turns[1].resolve("two done"); await Promise.all([first, second]); await gateway.sendEvent("qq:chat", "event"); assert.equal(firstSent.filter((outgoing) => outgoing.text === "event").length, 0); assert.equal(secondSent.at(-1)?.text, "event"); assert.equal(secondSent.at(-1)?.replyTo, undefined); }); test("sendEvent drops safely when the chat has no known target", async () => { const { gateway } = createGateway(); await gateway.sendEvent("qq:unknown", "event"); }); test("sendEvent delivery failures are logged safely without message content", async () => { const { runtime, gateway } = createGateway(); const errors: string[] = []; const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { if (!outgoing.replyTo) throw new Error("sensitive provider response"); } }; const turn = gateway.receive(message("work", "event-failure"), adapter); await waitFor(() => runtime.turns.length === 1); runtime.turns[0].resolve("done"); await turn; const originalError = console.error; console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; try { await gateway.sendEvent("qq:chat", "event text must not leak"); } finally { console.error = originalError; } assert.deepEqual(errors, ["Gateway event send failed (platform=qq)"]); }); test("status and help reach the runtime or reply directly", async () => { const { runtime, gateway } = createGateway(); const adapter = recordingAdapter(); const turn = gateway.receive(message("work"), adapter, { synchronous: true }); await waitFor(() => runtime.turns.length === 1); const status = await gateway.receive(message("/status"), adapter, { synchronous: true }); assert.match(status.reply || "", /running=true/); const help = await gateway.receive(message("/help"), adapter, { synchronous: true }); assert.match(help.reply || "", /自然语言/); assert.match(help.reply || "", /\/confirm/); 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 () => { 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\] 修复登录页/); runtime.proposals = []; const emptyList = await gateway.receive(message("/list"), adapter, { synchronous: true }); assert.equal(emptyList.reply, "当前没有提案。"); const confirm = await gateway.receive(message("/confirm"), adapter, { synchronous: true }); assert.equal(runtime.confirmed, 1); assert.equal(confirm.reply, "已确认,继续执行。"); const stop = await gateway.receive(message("/stop"), adapter, { synchronous: true }); assert.equal(runtime.stopped, 1); assert.equal(stop.reply, "已停止当前任务。"); const cancel = await gateway.receive(message("/cancel"), adapter, { synchronous: true }); assert.equal(runtime.cancelled, 1); assert.equal(cancel.reply, "已取消最近的提案。"); runtime.confirmResult = false; runtime.stopResult = false; runtime.cancelResult = false; assert.equal((await gateway.receive(message("/confirm"), 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("synchronous work sends no delivery messages", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; const turn = gateway.receive(message("work"), recordingAdapter(sent), { synchronous: true }); await waitFor(() => runtime.turns.length === 1); runtime.turns[0].resolve("done"); assert.deepEqual(await turn, { ok: true, reply: "done" }); assert.deepEqual(sent, []); }); test("asynchronous help replies with sequence one without reaching the runtime", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; await gateway.receive(message("/help", "help"), recordingAdapter(sent)); assert.deepEqual(messagesFor(sent, "help").map((outgoing) => outgoing.replySequence), [1]); assert.equal(runtime.turns.length, 0); }); test("asynchronous commands reply with sequence one without reaching prompt", async () => { const { runtime, gateway } = createGateway(); const sent: OutgoingMessage[] = []; runtime.proposals = [fakeProposal()]; const result = await gateway.receive(message("/list", "list"), recordingAdapter(sent)); assert.equal(result.ok, true); assert.equal(runtime.turns.length, 0); assert.deepEqual(messagesFor(sent, "list").map((outgoing) => outgoing.replySequence), [1]); assert.match(messagesFor(sent, "list")[0].text, /proposal-1/); });