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
This commit is contained in:
committed by
Peter Steinberger
parent
85fab7afe3
commit
9185fdc896
@@ -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()}`;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user