From 9185fdc89694f43b113954eeb0dddd8af5931cc4 Mon Sep 17 00:00:00 2001 From: Samrat Jha Date: Fri, 9 Jan 2026 13:35:55 -0500 Subject: [PATCH] fix(queue): deduplicate followup queue entries to prevent duplicate responses ## Problem When messages arrived while the agent was busy processing a previous message, the same message could be enqueued multiple times into the followup queue. This happened because Discord's event system can emit the same message multiple times (e.g., during reconnects or due to slow listener processing), and the followup queue had no deduplication logic. This caused the bot to respond to the same user message 2-4+ times. ## Solution Add simple exact-match deduplication in `enqueueFollowupRun()`: if a prompt is already in the queue, skip adding it again. Extracted into a small `isPromptAlreadyQueued()` helper for clarity. ## Testing - Added test cases for deduplication (same prompt rejected, different accepted) - Manually verified on Discord: single response per message even when multiple events fire during slow agent processing --- .../reply/queue.collect-routing.test.ts | 105 ++++++++++++++++++ src/auto-reply/reply/queue.ts | 16 +++ 2 files changed, 121 insertions(+) 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") {