forked from EduCraft/curriculum-project-hub
feat: add message burst debouncing (MessageBatcher)
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks, max 8 messages/4000 chars before immediate flush, per (chatId, sender) key - Integrate into trigger: text messages enqueued, slash commands and file/image bypass batching, locked projects respond immediately - Add unit tests (8) for batcher lifecycle and edge cases
This commit is contained in:
@@ -96,7 +96,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||
await seedProject("proj-1", "chat-1");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
|
||||
|
||||
@@ -125,9 +125,51 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runAgentCalls[0]?.maxTurns).toBe(7);
|
||||
});
|
||||
|
||||
it("batches quick text messages from the same chat and sender into one run", async () => {
|
||||
await seedProject("proj-1b", "chat-1b");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
messageBatcherOptions: { debounceMs: 10_000, maxMessages: 2 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-1b", "@_user_1 第一段"), rt);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
|
||||
await trigger(makeEvent("chat-1b", "@_user_1 第二段"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.status).toBe("COMPLETED");
|
||||
expect(runs[0]?.prompt).toBe("第一段\n第二段");
|
||||
});
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
expect(runAgentCalls[0]?.prompt).toBe("第一段\n第二段");
|
||||
});
|
||||
|
||||
it("/new bypasses message batching", async () => {
|
||||
await seedProject("proj-1c", "chat-1c");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
messageBatcherOptions: { debounceMs: 10_000 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-1c", "@_user_1 /new"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
||||
await seedProject("proj-2", "chat-2", { role: "READ" });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
||||
|
||||
@@ -147,7 +189,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
data: { projectId: "proj-3", runId: existingRun.id },
|
||||
});
|
||||
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
||||
@@ -159,7 +201,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
||||
await seedProject("proj-4", "chat-4");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
||||
|
||||
@@ -171,7 +213,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("ignores messages without @bot mention", async () => {
|
||||
await seedProject("proj-5", "chat-5");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
const event: MessageReceiveEvent = {
|
||||
message: {
|
||||
@@ -193,7 +235,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/new archives current session (no run created)", async () => {
|
||||
await seedProject("proj-6", "chat-6");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
// First @bot creates a session + run.
|
||||
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
|
||||
@@ -216,7 +258,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/resume un-archives the most recent session", async () => {
|
||||
await seedProject("proj-7", "chat-7");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
// Create + archive a session via /new.
|
||||
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
||||
@@ -236,7 +278,7 @@ 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 });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
@@ -255,7 +297,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("unknown slash command falls through to agent", async () => {
|
||||
await seedProject("proj-9", "chat-9");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
||||
// Should create a run (falls through as a normal prompt).
|
||||
@@ -272,7 +314,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-10", roleId: "review", principalType: "USER", principalId: "ou_other" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
@@ -287,7 +329,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
@@ -301,7 +343,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
|
||||
await seedProject("proj-12", "chat-12");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
|
||||
|
||||
@@ -315,7 +357,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("dedups a redelivered event by event_id (no second run)", async () => {
|
||||
await seedProject("proj-13", "chat-13");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
// First delivery: processes normally.
|
||||
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
|
||||
@@ -338,7 +380,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("dedups flattened websocket events by top-level event_id", async () => {
|
||||
await seedProject("proj-13b", "chat-13b");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
const event: MessageReceiveEvent = {
|
||||
...makeEvent("chat-13b", "@_user_1 写教案"),
|
||||
event_id: "evt-flat-1",
|
||||
@@ -376,7 +418,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
sender: { id: "ou_teacher_2", sender_type: "user" },
|
||||
body: { content: JSON.stringify({ text: "上一条需求: 把第二题改成探究题。" }) },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
const baseEvent = makeEvent("chat-13c", "@_user_1 继续这个改法");
|
||||
const event: MessageReceiveEvent = {
|
||||
...baseEvent,
|
||||
@@ -418,7 +460,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("writes audit entries across the run lifecycle", async () => {
|
||||
await seedProject("proj-14", "chat-14");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
@@ -433,7 +475,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
|
||||
await seedProject("proj-15", "chat-15");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
|
||||
Reference in New Issue
Block a user