From 7e59803df2830854874f1c9f48d30f4e89f6a980 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 01:05:50 +0000 Subject: [PATCH] refactor(queue): use stable tuple key for recent message dedupe --- src/auto-reply/reply/queue/enqueue.ts | 10 ++++-- src/auto-reply/reply/reply-flow.test.ts | 45 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/auto-reply/reply/queue/enqueue.ts b/src/auto-reply/reply/queue/enqueue.ts index d6481d2e9..7743048a7 100644 --- a/src/auto-reply/reply/queue/enqueue.ts +++ b/src/auto-reply/reply/queue/enqueue.ts @@ -14,13 +14,17 @@ function buildRecentMessageIdKey(run: FollowupRun, queueKey: string): string | u if (!messageId) { return undefined; } - const route = [ + // Use JSON tuple serialization to avoid delimiter-collision edge cases when + // channel/to/account values contain "|" characters. + return JSON.stringify([ + "queue", + queueKey, run.originatingChannel ?? "", run.originatingTo ?? "", run.originatingAccountId ?? "", run.originatingThreadId == null ? "" : String(run.originatingThreadId), - ].join("|"); - return `${queueKey}|${route}|${messageId}`; + messageId, + ]); } function isRunAlreadyQueued( diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 93ff85ce1..575ac7f17 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -743,6 +743,51 @@ describe("followup queue deduplication", () => { expect(calls).toHaveLength(1); }); + it("does not collide recent message-id keys when routing contains delimiters", async () => { + const key = `test-dedup-key-collision-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + done.resolve(); + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "first", + messageId: "same-id", + originatingChannel: "signal|group", + originatingTo: "peer", + }), + settings, + ); + expect(first).toBe(true); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + // Different routing dimensions can produce identical pipe-joined strings. + // This must not be deduplicated as a replay of the first run. + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "second", + messageId: "same-id", + originatingChannel: "signal", + originatingTo: "group|peer", + }), + settings, + ); + expect(second).toBe(true); + }); + it("deduplicates exact prompt when routing matches and no message id", async () => { const key = `test-dedup-whatsapp-${Date.now()}`; const settings: QueueSettings = {