feat: delay long-running task notices

This commit is contained in:
zenord
2026-08-17 15:35:18 +08:00
parent 2795567f8c
commit 680a449cd8
6 changed files with 589 additions and 164 deletions
+374 -121
View File
@@ -1,162 +1,415 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ConversationRuntime } from "../src/acp/types.js";
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 { Gateway, type GatewayTimer } from "../src/core/gateway.js";
import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js";
interface PendingTurn {
request: ConversationRequest;
resolve(text: string): void;
reject(error: Error): void;
}
class FakeRuntime implements ConversationRuntime {
prompts = 0; cancelled = 0; resets = 0; promptError?: Error;
private readonly releases: Array<() => void> = [];
async prompt() {
this.prompts++;
if (this.promptError) throw this.promptError;
await new Promise<void>((resolve) => { this.releases.push(resolve); });
return { text: "done", botId: "test-bot", agentId: "kimi" };
readonly turns: PendingTurn[] = [];
cancelled = 0;
resets = 0;
idleSeconds = 0;
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++; this.releaseNext(); return true; }
async cancel() { this.cancelled++; this.turns.at(-1)?.resolve("cancelled"); return true; }
async reset() { this.resets++; }
status() { return { bot: "test-bot", agent: "kimi", workspace: "/tmp", running: this.releases.length > 0, phase: "processing" }; }
status() {
return {
bot: "test-bot", agent: "kimi", workspace: "/tmp",
running: this.turns.length > 0, phase: "processing", idleSeconds: this.idleSeconds
};
}
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
async shutdown() {}
releaseNext() { this.releases.shift()?.(); }
}
interface ScheduledTimer extends GatewayTimer {
deadline: number;
callback: () => void;
cancelled: boolean;
order: number;
}
class FakeClock {
nowMs = 0;
private order = 0;
readonly timers: ScheduledTimer[] = [];
readonly now = () => this.nowMs;
readonly schedule = (callback: () => void, delayMs: number): GatewayTimer => {
const timer: ScheduledTimer = {
deadline: this.nowMs + delayMs,
callback,
cancelled: false,
order: this.order++,
cancel() { timer.cancelled = true; }
};
this.timers.push(timer);
return timer;
};
set(timeMs: number) { this.nowMs = timeMs; }
async advanceTo(timeMs: number) {
while (true) {
const next = this.timers
.filter((timer) => !timer.cancelled && timer.deadline <= timeMs)
.sort((left, right) => left.deadline - right.deadline || left.order - right.order)[0];
if (!next) break;
next.cancelled = true;
this.nowMs = next.deadline;
next.callback();
await flush();
}
this.nowMs = timeMs;
}
async jumpTo(timeMs: number) {
this.nowMs = timeMs;
const due = this.timers
.filter((timer) => !timer.cancelled && timer.deadline <= timeMs)
.sort((left, right) => left.deadline - right.deadline || left.order - right.order);
for (const timer of due) {
if (timer.cancelled) continue;
timer.cancelled = true;
timer.callback();
await flush();
}
}
activeTimers() { return this.timers.filter((timer) => !timer.cancelled).length; }
}
const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
const message = (text: string): IncomingMessage => ({ platform: "qq", chatId: "chat", userId: "user", text });
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 new Promise((resolve) => setTimeout(resolve, 5));
for (let count = 0; count < 50 && !condition(); count++) await flush();
assert.equal(condition(), true);
};
function recordingAdapter(sent: string[] = []): PlatformAdapter {
return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing.text); } };
function recordingAdapter(sent: OutgoingMessage[] = []): PlatformAdapter {
return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } };
}
function createGateway(runtime = new FakeRuntime(), clock = new FakeClock()) {
return { runtime, clock, gateway: new Gateway(policy, runtime, { now: clock.now, schedule: clock.schedule }) };
}
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("completion and timer callbacks are safe in both orders at 15 seconds", async () => {
{
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "completion-first"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
clock.set(15_000);
runtime.turns[0].resolve("done");
await turn;
await clock.advanceTo(15_000);
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
}
{
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "timer-first"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
runtime.turns[0].resolve("done");
await turn;
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [
["这条还在处理,完成后我会直接回复。", 1],
["done", 2]
]);
}
});
test("running and queued notices are independent and queued work announces start", async () => {
const { runtime, clock, 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 === 1);
await clock.advanceTo(15_000);
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.text), ["这条还在处理,完成后我会直接回复。"]);
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => outgoing.text), ["前面还有任务,这条还在排队,轮到后我马上处理。"]);
runtime.turns[0].resolve("first done");
await waitFor(() => runtime.turns.length === 2);
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => [outgoing.text, outgoing.replySequence]), [
["前面还有任务,这条还在排队,轮到后我马上处理。", 1],
["轮到这条了,我开始处理。", 2]
]);
runtime.turns[1].resolve("second done");
await Promise.all([first, second]);
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.replySequence), [1, 2]);
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => outgoing.replySequence), [1, 2, 3]);
});
test("running reminders fire at 60, 180, and 480 seconds with dynamic activity text", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "reminders"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
runtime.idleSeconds = 10;
await clock.advanceTo(60_000);
runtime.idleSeconds = 60;
await clock.advanceTo(180_000);
runtime.idleSeconds = 180;
await clock.advanceTo(480_000);
assert.deepEqual(sent.map((outgoing) => outgoing.text), [
"这条还在处理,完成后我会直接回复。",
"这条还在处理中,刚刚还有进展;完成后我会直接回复。",
"这条还在处理中,最近 60 秒没新进展;完成后我会直接回复。",
"这条还在处理中,最近 3 分钟没新进展;完成后我会直接回复。"
]);
runtime.turns[0].resolve("done");
await turn;
});
test("queued reminders use queue wording and sample the active ACP status", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent);
const first = gateway.receive(message("one", "active"), adapter);
const second = gateway.receive(message("two", "queued"), adapter);
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
runtime.idleSeconds = 45;
await clock.advanceTo(60_000);
assert.equal(messagesFor(sent, "queued").at(-1)?.text,
"前面的任务还在处理,最近 45 秒没新进展;这条仍在排队,轮到后我马上处理。");
runtime.turns[0].resolve("one done");
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("two done");
await Promise.all([first, second]);
});
test("a delayed timer jump emits one reminder instead of backfilling every deadline", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "jump"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
await clock.jumpTo(1_000_000);
assert.equal(sent.length, 2);
assert.equal(clock.activeTimers(), 1);
runtime.turns[0].resolve("done");
await turn;
});
test("completion and shutdown cancel all inbound timers", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const first = gateway.receive(message("one", "complete"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
assert.equal(clock.activeTimers(), 1);
runtime.turns[0].resolve("done");
await first;
assert.equal(clock.activeTimers(), 0);
const second = gateway.receive(message("two", "shutdown"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 2);
assert.equal(clock.activeTimers(), 1);
gateway.shutdown();
assert.equal(clock.activeTimers(), 0);
await clock.advanceTo(600_000);
assert.deepEqual(messagesFor(sent, "shutdown"), []);
runtime.turns[1].resolve("done after shutdown");
await second;
});
test("prompt does not wait for a notice send and final delivery delay releases the chat lock", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
let releaseFirstSend!: () => void;
const firstSend = new Promise<void>((resolve) => { releaseFirstSend = resolve; });
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) {
sent.push(outgoing);
if (outgoing.replyTo === "first" && outgoing.replySequence === 1) await firstSend;
}
};
const first = gateway.receive(message("one", "first"), adapter);
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
assert.equal(runtime.turns.length, 1);
runtime.turns[0].resolve("first done");
const second = gateway.receive(message("two", "second"), adapter);
await waitFor(() => runtime.turns.length === 2);
assert.equal(messagesFor(sent, "first").length, 1);
releaseFirstSend();
runtime.turns[1].resolve("second done");
await Promise.all([first, second]);
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.text), [
"这条还在处理,完成后我会直接回复。", "first done"
]);
});
test("delivery failures are logged safely and do not block later sends", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const errors: string[] = [];
let sends = 0;
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) {
sent.push(outgoing);
if (++sends === 1) 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);
await clock.advanceTo(15_000);
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("a failed final send does not turn a successful runtime result into Agent error", 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("provider detail"); }
};
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
try {
const turn = gateway.receive(message("work", "final-failure"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("real result");
assert.deepEqual(await turn, { ok: true, reply: "real result" });
} finally {
console.error = originalError;
}
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["real result"]);
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("status, cancel, and help bypass the chat lock while new remains serial", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const adapter = recordingAdapter();
const { runtime, clock, gateway } = createGateway();
const adapter = recordingAdapter();
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
await waitFor(() => runtime.prompts === 1);
await waitFor(() => runtime.turns.length === 1);
clock.set(5_000);
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
assert.match(status.reply || "", /gatewayRunning=true/);
assert.match(status.reply || "", /queued=0/);
assert.match(status.reply || "", /gatewayRunningSeconds=5/);
assert.match((await gateway.receive(message("/help"), adapter, { synchronous: true })).reply || "", /\/status/);
const cancelled = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
assert.equal(cancelled.reply, "Cancellation requested.");
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "Cancellation requested.");
await turn;
const second = gateway.receive(message("work"), adapter, { synchronous: true });
await waitFor(() => runtime.prompts === 2);
await waitFor(() => runtime.turns.length === 2);
const reset = gateway.receive(message("/new"), adapter, { synchronous: true });
await new Promise((resolve) => setTimeout(resolve, 10));
await flush();
assert.equal(runtime.resets, 0);
runtime.releaseNext();
runtime.turns[1].resolve("done");
await second;
await reset;
assert.equal(runtime.resets, 1);
});
test("asynchronous messages acknowledge processing and queue state", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: string[] = []; const adapter = recordingAdapter(sent);
const first = gateway.receive(message("one"), adapter);
await waitFor(() => runtime.prompts === 1);
const second = gateway.receive(message("two"), adapter);
await waitFor(() => sent.length >= 2);
assert.deepEqual(sent.slice(0, 2), [
"已收到,正在处理。可随时发送 /status 查看状态。",
"已收到,已排队。可随时发送 /status 查看状态。"
]);
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
assert.match(status.reply || "", /queued=1/);
assert.match(status.reply || "", /gatewayRunningSeconds=\d+/);
runtime.releaseNext();
await waitFor(() => runtime.prompts === 2);
runtime.releaseNext();
await Promise.all([first, second]);
});
test("prompt waits for acknowledgement and final reply uses sequence two", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: OutgoingMessage[] = [];
let releaseAcknowledgement!: () => void;
const acknowledgement = new Promise<void>((resolve) => { releaseAcknowledgement = resolve; });
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) {
sent.push(outgoing);
if (outgoing.replySequence === 1) await acknowledgement;
}
};
const turn = gateway.receive({ ...message("work"), messageId: "message-1" }, adapter);
await waitFor(() => sent.length === 1);
assert.equal(runtime.prompts, 0);
assert.equal(sent[0].replySequence, 1);
releaseAcknowledgement();
await waitFor(() => runtime.prompts === 1);
runtime.releaseNext();
await turn;
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replyTo, outgoing.replySequence]), [
["已收到,正在处理。可随时发送 /status 查看状态。", "message-1", 1],
["done", "message-1", 2]
]);
});
test("acknowledgement failure is logged safely and does not block work", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const errors: string[] = [];
let sends = 0;
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage() { if (++sends === 1) 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"), adapter);
await waitFor(() => runtime.prompts === 1);
runtime.releaseNext();
assert.equal((await turn).reply, "done");
} finally {
console.error = originalError;
}
assert.deepEqual(errors, ["Gateway acknowledgement send failed (platform=qq)"]);
});
test("asynchronous prompt errors reply with sequence two", async () => {
const runtime = new FakeRuntime(); runtime.promptError = new Error("failed");
const gateway = new Gateway(policy, runtime); const sent: OutgoingMessage[] = [];
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); }
};
const result = await gateway.receive(message("work"), adapter);
assert.equal(result.ok, false);
assert.deepEqual(sent.map((outgoing) => outgoing.replySequence), [1, 2]);
});
test("single asynchronous command replies with sequence one", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: OutgoingMessage[] = [];
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); }
};
await gateway.receive(message("/help"), adapter);
assert.equal(sent.length, 1);
assert.equal(sent[0].replySequence, 1);
});
test("synchronous messages do not send acknowledgements", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const sent: string[] = []; const adapter = recordingAdapter(sent);
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
await waitFor(() => runtime.prompts === 1);
test("synchronous work sends no notices or delivery messages", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work"), recordingAdapter(sent), { synchronous: true });
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(600_000);
assert.deepEqual(sent, []);
runtime.releaseNext();
await turn;
runtime.turns[0].resolve("done");
assert.deepEqual(await turn, { ok: true, reply: "done" });
assert.deepEqual(sent, []);
});
test("fixed Bot status and retired role commands never reach ACP", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(policy, runtime); const adapter = recordingAdapter();
for (const command of ["/roles", "/role ops", "/agents", "/agent ops"]) {
assert.match((await gateway.receive(message(command), adapter, { synchronous: true })).reply || "", /removed in Config v3/);
}
assert.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /bot=test-bot/);
assert.equal(runtime.prompts, 0);
test("asynchronous help replies with sequence one", 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 new waits for prior work and then replies with sequence one", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent);
const work = gateway.receive(message("work", "work"), adapter);
await waitFor(() => runtime.turns.length === 1);
const reset = gateway.receive(message("/new", "new"), adapter);
await flush();
assert.equal(runtime.resets, 0);
assert.deepEqual(messagesFor(sent, "new"), []);
runtime.turns[0].resolve("done");
await work;
assert.deepEqual(await reset, { ok: true, reply: "Started a new native ACP session for this chat." });
assert.equal(runtime.resets, 1);
assert.deepEqual(messagesFor(sent, "new").map((outgoing) => [outgoing.text, outgoing.replySequence]), [
["Started a new native ACP session for this chat.", 1]
]);
});
test("asynchronous retired-role command replies with sequence one without reaching ACP", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const result = await gateway.receive(message("/roles", "retired"), recordingAdapter(sent));
assert.match(result.reply || "", /removed in Config v3/);
assert.equal(runtime.turns.length, 0);
assert.deepEqual(messagesFor(sent, "retired").map((outgoing) => outgoing.replySequence), [1]);
});