diff --git a/src/auto-reply/reply/queue.collect-routing.test.ts b/src/auto-reply/reply/queue.collect-routing.test.ts index 089f38ae1..118a098cd 100644 --- a/src/auto-reply/reply/queue.collect-routing.test.ts +++ b/src/auto-reply/reply/queue.collect-routing.test.ts @@ -29,6 +29,111 @@ function createRun(params: { }; } +describe("followup queue deduplication", () => { + it("deduplicates messages with same Discord message_id", async () => { + const key = `test-dedup-message-id-${Date.now()}`; + const calls: FollowupRun[] = []; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same prompt should be deduplicated + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(second).toBe(false); + + // Third enqueue with different prompt should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] World", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(third).toBe(true); + + scheduleFollowupDrain(key, runFollowup); + await expect.poll(() => calls.length).toBe(1); + // Should collect both unique messages + expect(calls[0]?.prompt).toContain( + "[Queued messages while agent was busy]", + ); + }); + + it("deduplicates across different providers using exact prompt match", async () => { + const key = `test-dedup-whatsapp-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same prompt should be deduplicated + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(second).toBe(false); + + // Third enqueue with different prompt should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world 2", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(third).toBe(true); + }); +}); + describe("followup queue collect routing", () => { it("does not collect when destinations differ", async () => { const key = `test-collect-diff-to-${Date.now()}`; diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 4b14afa43..4bb7ec630 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -337,6 +337,16 @@ function getFollowupQueue( FOLLOWUP_QUEUES.set(key, created); return created; } +/** + * Check if a prompt is already queued using exact match. + */ +function isPromptAlreadyQueued( + prompt: string, + queue: FollowupQueueState, +): boolean { + return queue.items.some((item) => item.prompt === prompt); +} + export function enqueueFollowupRun( key: string, run: FollowupRun, @@ -345,6 +355,12 @@ export function enqueueFollowupRun( const queue = getFollowupQueue(key, settings); queue.lastEnqueuedAt = Date.now(); queue.lastRun = run.run; + + // Deduplicate: skip if the same prompt is already queued. + if (isPromptAlreadyQueued(run.prompt, queue)) { + return false; + } + const cap = queue.cap; if (cap > 0 && queue.items.length >= cap) { if (queue.dropPolicy === "new") {