Files
gori-agent/test/gateway.test.ts
T
2026-08-18 18:48:47 +08:00

301 lines
12 KiB
TypeScript

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> = {}): 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<void>((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/);
});