feat: add per-project message trigger wait queue

- TriggerQueue: FIFO per-project queue, max 5 items, 5min TTL expiry
- Replace reject-when-locked with queue: users get position feedback
- Run completion auto-starts next queued trigger (skips expired items)
- /reset clears project queue; 60s periodic purge interval
- Add unit tests (10) for queue lifecycle and edge cases
This commit is contained in:
2026-07-08 17:31:43 +08:00
parent 4479f88f4f
commit 36c0801e6f
5 changed files with 442 additions and 16 deletions
+95 -3
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
@@ -179,7 +180,7 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0);
});
it("replies busy when project is already locked (ADR-0002)", async () => {
it("queues a text trigger when project is already locked (ADR-0002)", async () => {
await seedProject("proj-3", "chat-3");
// Manually create a lock by inserting a run + lock.
const existingRun = await prisma.agentRun.create({
@@ -192,13 +193,59 @@ describe("trigger full lifecycle (integration)", () => {
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
expect(rt.reactions.some((reaction) => reaction.emoji === "OnIt")).toBe(false);
expect(runAgentCalls).toHaveLength(0);
// No new run created.
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
});
it("starts the next queued text trigger when the current run finishes", async () => {
await seedProject("proj-3b", "chat-3b");
const firstRun = deferred<RunResult>();
const secondRun = deferred<RunResult>();
const pendingRuns = [firstRun, secondRun];
const queuedRunAgent: TestRunner = async (req) => {
runAgentCalls.push(req);
req.onStream?.({ type: "text-delta", text: `mock response ${runAgentCalls.length}` });
const pendingRun = pendingRuns.shift();
if (pendingRun === undefined) {
throw new Error("unexpected extra run");
}
return pendingRun.promise;
};
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent: queuedRunAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-3b", "@_user_1 第一个请求"), rt);
await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(1);
});
await trigger(makeEvent("chat-3b", "@_user_1 第二个请求"), rt);
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
expect(runAgentCalls).toHaveLength(1);
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
await vi.waitFor(() => {
expect(runAgentCalls).toHaveLength(2);
});
expect(runAgentCalls[1]?.prompt).toBe("第二个请求");
secondRun.resolve(completedRunResult("second done", "sdk-session-second"));
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany({ orderBy: { createdAt: "asc" } });
expect(runs).toHaveLength(2);
expect(runs.every((run) => run.status === "COMPLETED")).toBe(true);
});
});
it("ignores messages from unbound chats (ADR-0001)", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
@@ -278,7 +325,15 @@ describe("trigger full lifecycle (integration)", () => {
it("/reset archives current session", async () => {
await seedProject("proj-8", "chat-8");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
const queue = new TriggerQueue();
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
triggerQueue: queue,
});
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
@@ -287,8 +342,19 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.status).toBe("COMPLETED");
});
const queuedEvent = makeEvent("chat-8", "@_user_1 后续需求");
queue.enqueue("proj-8", {
chatId: "chat-8",
prompt: extractPrompt(queuedEvent.message) ?? "后续需求",
msg: queuedEvent.message,
senderOpenId: "ou_test_user",
actor: { feishuOpenId: "ou_test_user", chatId: "chat-8" },
});
expect(queue.length("proj-8")).toBe(1);
await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt);
expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。");
expect(queue.length("proj-8")).toBe(0);
const sessions = await prisma.agentSession.findMany();
expect(sessions).toHaveLength(1);
@@ -490,6 +556,32 @@ describe("trigger full lifecycle (integration)", () => {
});
});
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function completedRunResult(text: string, sdkSessionId: string): RunResult {
return {
status: "completed",
text,
usage: { inputTokens: 10, outputTokens: 5 },
numTurns: 1,
sdkSessionId,
};
}
afterAll(async () => {
await prisma.$disconnect();
});
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TriggerQueue, type QueuedTrigger } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
describe("TriggerQueue", () => {
afterEach(() => {
vi.useRealTimers();
});
it("dequeues triggers in FIFO order", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-1", makeTrigger("second"));
expect(queue.dequeue("project-1")?.prompt).toBe("first");
expect(queue.dequeue("project-1")?.prompt).toBe("second");
});
it("returns the 1-based queue position from enqueue", () => {
const queue = new TriggerQueue();
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(3);
});
it("returns 0 when the project queue is full", () => {
const queue = new TriggerQueue({ maxQueueSize: 2 });
expect(queue.enqueue("project-1", makeTrigger("first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("second"))).toBe(2);
expect(queue.enqueue("project-1", makeTrigger("third"))).toBe(0);
expect(queue.length("project-1")).toBe(2);
});
it("returns null when dequeueing an empty queue", () => {
const queue = new TriggerQueue();
expect(queue.dequeue("project-1")).toBeNull();
});
it("peeks without removing the trigger", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
expect(queue.peek("project-1")?.prompt).toBe("first");
expect(queue.length("project-1")).toBe(1);
expect(queue.dequeue("project-1")?.prompt).toBe("first");
});
it("reports length and pending state per project", () => {
const queue = new TriggerQueue();
expect(queue.length("project-1")).toBe(0);
expect(queue.hasPending("project-1")).toBe(false);
queue.enqueue("project-1", makeTrigger("first"));
expect(queue.length("project-1")).toBe(1);
expect(queue.hasPending("project-1")).toBe(true);
});
it("purges expired triggers", () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const queue = new TriggerQueue({ maxWaitMs: 100 });
queue.enqueue("project-1", makeTrigger("old"));
vi.setSystemTime(1_050);
queue.enqueue("project-1", makeTrigger("fresh"));
vi.setSystemTime(1_101);
expect(queue.purgeExpired()).toBe(1);
expect(queue.length("project-1")).toBe(1);
expect(queue.dequeue("project-1")?.prompt).toBe("fresh");
});
it("clears all triggers for a project", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-1", makeTrigger("second"));
queue.enqueue("project-2", makeTrigger("other"));
expect(queue.clear("project-1")).toBe(2);
expect(queue.length("project-1")).toBe(0);
expect(queue.length("project-2")).toBe(1);
});
it("clears all project queues", () => {
const queue = new TriggerQueue();
queue.enqueue("project-1", makeTrigger("first"));
queue.enqueue("project-2", makeTrigger("other"));
queue.clearAll();
expect(queue.hasPending("project-1")).toBe(false);
expect(queue.hasPending("project-2")).toBe(false);
});
it("keeps multiple project queues independent", () => {
const queue = new TriggerQueue();
expect(queue.enqueue("project-1", makeTrigger("p1-first"))).toBe(1);
expect(queue.enqueue("project-2", makeTrigger("p2-first"))).toBe(1);
expect(queue.enqueue("project-1", makeTrigger("p1-second"))).toBe(2);
expect(queue.dequeue("project-2")?.prompt).toBe("p2-first");
expect(queue.dequeue("project-1")?.prompt).toBe("p1-first");
expect(queue.dequeue("project-1")?.prompt).toBe("p1-second");
});
});
function makeTrigger(prompt: string): Omit<QueuedTrigger, "projectId" | "enqueuedAt"> {
return {
chatId: "chat-1",
prompt,
msg: makeMessage(prompt),
senderOpenId: "ou-user",
actor: { feishuOpenId: "ou-user", chatId: "chat-1" },
};
}
function makeMessage(prompt: string): MessageReceiveEvent["message"] {
return {
message_id: `message-${prompt}`,
chat_id: "chat-1",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: `@_user_1 ${prompt}` }),
mentions: [{ key: "@_user_1", id: { open_id: "ou-bot" }, name: "Bot" }],
};
}