forked from bai/curriculum-project-hub
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:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user