Files
openclaw/src/auto-reply/reply/queue.dedupe.test.ts
sandieman2 c67dc59b02 fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread (#90943)
* fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread

Two core bugs caused composed replies to be silently dropped (no delivery,
no error) when a second message arrived in the same thread mid-run:

1. dispatch-from-config: ensureDispatchReplyOperation only kept the
   dispatch-owned operation authoritative while it had no result. Once
   runReplyAgent completed the operation to drain queued follow-ups, a
   second same-thread inbound could claim the session and the first final
   reply would try to re-acquire the lane instead of finishing delivery,
   deadlocking behind the queued work. Keep the dispatch-owned operation
   authoritative through final delivery.

2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the
   channel target, not the routed thread, so a send in one thread could
   suppress a later reply in a different thread. Thread the routed thread
   id through buildReplyPayloads + follow-up delivery and only fall back to
   channel-only matching for providers without a thread-aware suppression
   matcher when neither side carries thread evidence.

Adds regression tests; existing Telegram topic-suppression behavior is
preserved by gating the thread guard to providers lacking a plugin matcher.

* fix(reply): preserve threaded message delivery evidence

* fix(reply): dedupe final payloads by delivery route

* fix(slack): preserve native send thread evidence

* fix(reply): preserve explicit reply thread evidence

* fix(reply): align explicit reply route dedupe

* fix(reply): preserve delivery lane through final dispatch

* fix(mattermost): preserve threaded tool send routes

* chore(plugin-sdk): refresh API baseline

* fix(reply): align final delivery route dedupe

* fix(reply): gate followups on final delivery

* fix(reply): keep send receipts private

* fix(reply): infer implicit message provider

* fix(reply): align routed threading policy

* fix(reply): preserve queued delivery context

* fix(reply): hydrate queued system event routes

* fix(reply): hydrate queued execution routes

* fix(reply): scope final delivery barriers

* fix(slack): preserve DM target aliases

* fix(reply): mirror resolved source thread routes

* fix(mattermost): retain delayed delivery barrier

* fix(codex): separate message routing from tool policy

* fix(reply): consume normalized Slack DM targets once

* fix(slack): remove stale target alias

* style(reply): satisfy changed lint gates

* fix(mattermost): preserve explicit reply targets

* test: align Slack reply branch checks

* fix(reply): persist overflow summaries to admitted session

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-14 09:11:05 -07:00

378 lines
9.7 KiB
TypeScript

// Tests follow-up queue message-id dedupe and drain scheduling behavior.
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import type { FollowupRun, QueueSettings } from "./queue.js";
import {
enqueueFollowupRun,
resetRecentQueuedMessageIdDedupe,
scheduleFollowupDrain,
} from "./queue.js";
import {
createDeferred,
createQueueTestRun as createRun,
installQueueRuntimeErrorSilencer,
} from "./queue.test-helpers.js";
installQueueRuntimeErrorSilencer();
const collectSettings: QueueSettings = {
mode: "collect",
debounceMs: 0,
cap: 50,
dropPolicy: "summarize",
};
function createFollowupCollector(expectedCalls = 1): {
calls: FollowupRun[];
done: ReturnType<typeof createDeferred<void>>;
runFollowup: (run: FollowupRun) => Promise<void>;
} {
const calls: FollowupRun[] = [];
const done = createDeferred<void>();
return {
calls,
done,
runFollowup: async (run: FollowupRun) => {
calls.push(run);
if (calls.length >= expectedCalls) {
done.resolve();
}
},
};
}
describe("followup queue deduplication", () => {
beforeEach(() => {
resetRecentQueuedMessageIdDedupe();
});
it("deduplicates messages with same Discord message_id", async () => {
const key = `test-dedup-message-id-${Date.now()}`;
const { calls, done, runFollowup } = createFollowupCollector();
const first = enqueueFollowupRun(
key,
createRun({
prompt: "[Discord Guild #test channel id:123] Hello",
messageId: "m1",
originatingChannel: "discord",
originatingTo: "channel:123",
}),
collectSettings,
);
expect(first).toBe(true);
const second = enqueueFollowupRun(
key,
createRun({
prompt: "[Discord Guild #test channel id:123] Hello (dupe)",
messageId: "m1",
originatingChannel: "discord",
originatingTo: "channel:123",
}),
collectSettings,
);
expect(second).toBe(false);
const third = enqueueFollowupRun(
key,
createRun({
prompt: "[Discord Guild #test channel id:123] World",
messageId: "m2",
originatingChannel: "discord",
originatingTo: "channel:123",
}),
collectSettings,
);
expect(third).toBe(true);
scheduleFollowupDrain(key, runFollowup);
await done.promise;
expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]");
});
it("deduplicates message ids when numeric and string thread ids share a route", () => {
const key = `test-dedup-thread-normalized-${Date.now()}`;
const first = enqueueFollowupRun(
key,
createRun({
prompt: "first",
messageId: "same-id",
originatingChannel: "telegram",
originatingTo: "-100123",
originatingThreadId: 42.9,
}),
collectSettings,
);
expect(first).toBe(true);
const second = enqueueFollowupRun(
key,
createRun({
prompt: "second",
messageId: "same-id",
originatingChannel: "telegram",
originatingTo: "-100123",
originatingThreadId: "42",
}),
collectSettings,
);
expect(second).toBe(false);
});
it("deduplicates same message_id after queue drain restarts", async () => {
const key = `test-dedup-after-drain-${Date.now()}`;
const { calls, done, runFollowup } = createFollowupCollector();
const first = enqueueFollowupRun(
key,
createRun({
prompt: "first",
messageId: "same-id",
originatingChannel: "signal",
originatingTo: "+10000000000",
}),
collectSettings,
);
expect(first).toBe(true);
scheduleFollowupDrain(key, runFollowup);
await done.promise;
const redelivery = enqueueFollowupRun(
key,
createRun({
prompt: "first-redelivery",
messageId: "same-id",
originatingChannel: "signal",
originatingTo: "+10000000000",
}),
collectSettings,
);
expect(redelivery).toBe(false);
expect(calls).toHaveLength(1);
});
it("deduplicates redelivery after reply policy changes", async () => {
const key = `test-dedup-policy-change-${Date.now()}`;
const { calls, done, runFollowup } = createFollowupCollector();
expect(
enqueueFollowupRun(
key,
createRun({
prompt: "first",
messageId: "same-id",
originatingChannel: "slack",
originatingTo: "U123",
originatingReplyToId: "101.001",
originatingReplyToMode: "off",
originatingChatType: "direct",
}),
collectSettings,
),
).toBe(true);
scheduleFollowupDrain(key, runFollowup);
await done.promise;
expect(
enqueueFollowupRun(
key,
createRun({
prompt: "redelivery",
messageId: "same-id",
originatingChannel: "slack",
originatingTo: "U123",
originatingReplyToId: "101.001",
originatingReplyToMode: "first",
originatingChatType: "direct",
}),
collectSettings,
),
).toBe(false);
expect(calls).toHaveLength(1);
});
it("deduplicates same message_id across distinct enqueue module instances", async () => {
const enqueueA = await importFreshModule<typeof import("./queue/enqueue.js")>(
import.meta.url,
"./queue/enqueue.js?scope=dedupe-a",
);
const enqueueB = await importFreshModule<typeof import("./queue/enqueue.js")>(
import.meta.url,
"./queue/enqueue.js?scope=dedupe-b",
);
const { clearSessionQueues } = await import("./queue.js");
const key = `test-dedup-cross-module-${Date.now()}`;
const { calls, done, runFollowup } = createFollowupCollector();
enqueueA.resetRecentQueuedMessageIdDedupe();
enqueueB.resetRecentQueuedMessageIdDedupe();
try {
expect(
enqueueA.enqueueFollowupRun(
key,
createRun({
prompt: "first",
messageId: "same-id",
originatingChannel: "signal",
originatingTo: "+10000000000",
}),
collectSettings,
),
).toBe(true);
scheduleFollowupDrain(key, runFollowup);
await done.promise;
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(
enqueueB.enqueueFollowupRun(
key,
createRun({
prompt: "first-redelivery",
messageId: "same-id",
originatingChannel: "signal",
originatingTo: "+10000000000",
}),
collectSettings,
),
).toBe(false);
expect(calls).toHaveLength(1);
} finally {
clearSessionQueues([key]);
enqueueA.resetRecentQueuedMessageIdDedupe();
enqueueB.resetRecentQueuedMessageIdDedupe();
}
});
it("does not collide recent message-id keys when routing contains delimiters", async () => {
const key = `test-dedup-key-collision-${Date.now()}`;
const { done, runFollowup } = createFollowupCollector();
const first = enqueueFollowupRun(
key,
createRun({
prompt: "first",
messageId: "same-id",
originatingChannel: "signal|group",
originatingTo: "peer",
}),
collectSettings,
);
expect(first).toBe(true);
scheduleFollowupDrain(key, runFollowup);
await done.promise;
const second = enqueueFollowupRun(
key,
createRun({
prompt: "second",
messageId: "same-id",
originatingChannel: "signal",
originatingTo: "group|peer",
}),
collectSettings,
);
expect(second).toBe(true);
});
it("deduplicates exact prompt when routing matches and no message id", () => {
const key = `test-dedup-whatsapp-${Date.now()}`;
const first = enqueueFollowupRun(
key,
createRun({
prompt: "Hello world",
originatingChannel: "whatsapp",
originatingTo: "+1234567890",
}),
collectSettings,
);
expect(first).toBe(true);
const second = enqueueFollowupRun(
key,
createRun({
prompt: "Hello world",
originatingChannel: "whatsapp",
originatingTo: "+1234567890",
}),
collectSettings,
);
expect(second).toBe(true);
const third = enqueueFollowupRun(
key,
createRun({
prompt: "Hello world 2",
originatingChannel: "whatsapp",
originatingTo: "+1234567890",
}),
collectSettings,
);
expect(third).toBe(true);
});
it("does not deduplicate across different providers without message id", () => {
const key = `test-dedup-cross-provider-${Date.now()}`;
const first = enqueueFollowupRun(
key,
createRun({
prompt: "Same text",
originatingChannel: "whatsapp",
originatingTo: "+1234567890",
}),
collectSettings,
);
expect(first).toBe(true);
const second = enqueueFollowupRun(
key,
createRun({
prompt: "Same text",
originatingChannel: "discord",
originatingTo: "channel:123",
}),
collectSettings,
);
expect(second).toBe(true);
});
it("can opt-in to prompt-based dedupe when message id is absent", () => {
const key = `test-dedup-prompt-mode-${Date.now()}`;
const first = enqueueFollowupRun(
key,
createRun({
prompt: "Hello world",
originatingChannel: "whatsapp",
originatingTo: "+1234567890",
}),
collectSettings,
"prompt",
);
expect(first).toBe(true);
const second = enqueueFollowupRun(
key,
createRun({
prompt: "Hello world",
originatingChannel: "whatsapp",
originatingTo: "+1234567890",
}),
collectSettings,
"prompt",
);
expect(second).toBe(false);
});
});