diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 8f288a72bed..d62f7844180 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -188,26 +188,23 @@ export async function buildReplyPayloads(params: { const dedupeRuntime = shouldCheckMessagingToolDedupe ? await loadReplyPayloadsDedupeRuntime() : null; - const suppressMessagingToolReplies = - dedupeRuntime?.shouldSuppressMessagingToolReplies({ - messageProvider: resolveOriginMessageProvider({ - originatingChannel: params.originatingChannel, - provider: params.messageProvider, - }), - messagingToolSentTargets, - originatingTo: resolveOriginMessageTo({ - originatingTo: params.originatingTo, - }), - accountId: resolveOriginAccountId({ - originatingAccountId: params.accountId, - }), - }) ?? false; - // Only dedupe against messaging tool sends for the same origin target. - // Cross-target sends (for example posting to another channel) must not - // suppress the current conversation's final reply. - // If target metadata is unavailable, keep legacy dedupe behavior. - const dedupeMessagingToolPayloads = - suppressMessagingToolReplies || messagingToolSentTargets.length === 0; + const messagingToolPayloadDedupe = dedupeRuntime?.resolveMessagingToolPayloadDedupe({ + messageProvider: resolveOriginMessageProvider({ + originatingChannel: params.originatingChannel, + provider: params.messageProvider, + }), + messagingToolSentTargets, + originatingTo: resolveOriginMessageTo({ + originatingTo: params.originatingTo, + }), + accountId: resolveOriginAccountId({ + originatingAccountId: params.accountId, + }), + }) ?? { + shouldDedupePayloads: shouldCheckMessagingToolDedupe && messagingToolSentTargets.length === 0, + suppressReplies: false, + }; + const dedupeMessagingToolPayloads = messagingToolPayloadDedupe.shouldDedupePayloads; const messagingToolSentMediaUrls = dedupeMessagingToolPayloads ? await normalizeSentMediaUrlsForDedupe({ sentMediaUrls: params.messagingToolSentMediaUrls ?? [], @@ -284,7 +281,7 @@ export async function buildReplyPayloads(params: { sentMediaUrls: blockSentMediaUrls, }) : contentSuppressedPayloads; - const replyPayloads = suppressMessagingToolReplies + const replyPayloads = messagingToolPayloadDedupe.suppressReplies ? [] : filteredPayloads.filter(isRenderablePayload); diff --git a/src/auto-reply/reply/followup-delivery.test.ts b/src/auto-reply/reply/followup-delivery.test.ts index 290dba629c4..df61cb10054 100644 --- a/src/auto-reply/reply/followup-delivery.test.ts +++ b/src/auto-reply/reply/followup-delivery.test.ts @@ -43,6 +43,32 @@ describe("resolveFollowupDeliveryPayloads", () => { ).toEqual([{ mediaUrl: undefined, mediaUrls: undefined }]); }); + it("does not dedupe text sent via messaging tool to another target", () => { + expect( + resolveFollowupDeliveryPayloads({ + cfg: baseConfig, + payloads: [{ text: "hello world!" }], + messageProvider: "telegram", + originatingTo: "telegram:123", + sentTexts: ["hello world!"], + sentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }], + }), + ).toEqual([{ text: "hello world!" }]); + }); + + it("does not dedupe media sent via messaging tool to another target", () => { + expect( + resolveFollowupDeliveryPayloads({ + cfg: baseConfig, + payloads: [{ text: "photo", mediaUrl: "file:///tmp/photo.jpg" }], + messageProvider: "telegram", + originatingTo: "telegram:123", + sentMediaUrls: ["file:///tmp/photo.jpg"], + sentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], + }), + ).toEqual([{ text: "photo", mediaUrl: "file:///tmp/photo.jpg" }]); + }); + it("suppresses replies when a messaging tool already sent to the same provider and target", () => { expect( resolveFollowupDeliveryPayloads({ diff --git a/src/auto-reply/reply/followup-delivery.ts b/src/auto-reply/reply/followup-delivery.ts index bce4f54740d..3b09c8e8027 100644 --- a/src/auto-reply/reply/followup-delivery.ts +++ b/src/auto-reply/reply/followup-delivery.ts @@ -12,7 +12,7 @@ import { applyReplyThreading, filterMessagingToolDuplicates, filterMessagingToolMediaDuplicates, - shouldSuppressMessagingToolReplies, + resolveMessagingToolPayloadDedupe, } from "./reply-payloads.js"; import { resolveReplyToMode } from "./reply-threading.js"; @@ -35,10 +35,11 @@ export function resolveFollowupDeliveryPayloads(params: { sentTargets?: MessagingToolSend[]; sentTexts?: string[]; }): ReplyPayload[] { - const replyToChannel = resolveOriginMessageProvider({ + const replyMessageProvider = resolveOriginMessageProvider({ originatingChannel: params.originatingChannel, provider: params.messageProvider, - }) as OriginatingChannelType | undefined; + }); + const replyToChannel = replyMessageProvider as OriginatingChannelType | undefined; const replyToMode = resolveReplyToMode( params.cfg, replyToChannel, @@ -62,16 +63,8 @@ export function resolveFollowupDeliveryPayloads(params: { replyToMode, replyToChannel, }); - const dedupedPayloads = filterMessagingToolDuplicates({ - payloads: replyTaggedPayloads, - sentTexts: params.sentTexts ?? [], - }); - const mediaFilteredPayloads = filterMessagingToolMediaDuplicates({ - payloads: dedupedPayloads, - sentMediaUrls: params.sentMediaUrls ?? [], - }); - const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: replyToChannel, + const messagingToolPayloadDedupe = resolveMessagingToolPayloadDedupe({ + messageProvider: replyMessageProvider, messagingToolSentTargets: params.sentTargets, originatingTo: resolveOriginMessageTo({ originatingTo: params.originatingTo, @@ -80,5 +73,17 @@ export function resolveFollowupDeliveryPayloads(params: { originatingAccountId: params.originatingAccountId, }), }); - return suppressMessagingToolReplies ? [] : mediaFilteredPayloads; + const mediaFilteredPayloads = messagingToolPayloadDedupe.shouldDedupePayloads + ? filterMessagingToolMediaDuplicates({ + payloads: replyTaggedPayloads, + sentMediaUrls: params.sentMediaUrls ?? [], + }) + : replyTaggedPayloads; + const dedupedPayloads = messagingToolPayloadDedupe.shouldDedupePayloads + ? filterMessagingToolDuplicates({ + payloads: mediaFilteredPayloads, + sentTexts: params.sentTexts ?? [], + }) + : mediaFilteredPayloads; + return messagingToolPayloadDedupe.suppressReplies ? [] : dedupedPayloads; } diff --git a/src/auto-reply/reply/reply-payloads-dedupe.runtime.ts b/src/auto-reply/reply/reply-payloads-dedupe.runtime.ts index 48fc5a88eff..0ed11bde7ab 100644 --- a/src/auto-reply/reply/reply-payloads-dedupe.runtime.ts +++ b/src/auto-reply/reply/reply-payloads-dedupe.runtime.ts @@ -1,5 +1,7 @@ export { filterMessagingToolDuplicates, filterMessagingToolMediaDuplicates, + resolveMessagingToolPayloadDedupe, shouldSuppressMessagingToolReplies, + type MessagingToolPayloadDedupeDecision, } from "./reply-payloads-dedupe.js"; diff --git a/src/auto-reply/reply/reply-payloads-dedupe.ts b/src/auto-reply/reply/reply-payloads-dedupe.ts index 7077dbc4760..d1f59b2ff0d 100644 --- a/src/auto-reply/reply/reply-payloads-dedupe.ts +++ b/src/auto-reply/reply/reply-payloads-dedupe.ts @@ -204,3 +204,28 @@ export function shouldSuppressMessagingToolReplies(params: { }); }); } + +export type MessagingToolPayloadDedupeDecision = { + shouldDedupePayloads: boolean; + suppressReplies: boolean; +}; + +export function resolveMessagingToolPayloadDedupe(params: { + messageProvider?: string; + messagingToolSentTargets?: MessagingToolSend[]; + originatingTo?: string; + accountId?: string; +}): MessagingToolPayloadDedupeDecision { + const sentTargets = params.messagingToolSentTargets ?? []; + const suppressReplies = shouldSuppressMessagingToolReplies({ + messageProvider: params.messageProvider, + messagingToolSentTargets: sentTargets, + originatingTo: params.originatingTo, + accountId: params.accountId, + }); + + return { + shouldDedupePayloads: suppressReplies || sentTargets.length === 0, + suppressReplies, + }; +} diff --git a/src/auto-reply/reply/reply-payloads.test.ts b/src/auto-reply/reply/reply-payloads.test.ts index ab2c5f0d0f2..0fb2cd9dd0a 100644 --- a/src/auto-reply/reply/reply-payloads.test.ts +++ b/src/auto-reply/reply/reply-payloads.test.ts @@ -3,6 +3,7 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../p import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { filterMessagingToolMediaDuplicates, + resolveMessagingToolPayloadDedupe, shouldSuppressMessagingToolReplies, } from "./reply-payloads.js"; @@ -244,3 +245,43 @@ describe("shouldSuppressMessagingToolReplies", () => { ).toBe(true); }); }); + +describe("resolveMessagingToolPayloadDedupe", () => { + it("dedupes by content when messaging tool target metadata is unavailable", () => { + expect( + resolveMessagingToolPayloadDedupe({ + messageProvider: "telegram", + originatingTo: "123", + }), + ).toEqual({ + shouldDedupePayloads: true, + suppressReplies: false, + }); + }); + + it("suppresses final replies when a messaging tool sent to the same route", () => { + expect( + resolveMessagingToolPayloadDedupe({ + messageProvider: "telegram", + originatingTo: "123", + messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }], + }), + ).toEqual({ + shouldDedupePayloads: true, + suppressReplies: true, + }); + }); + + it("keeps final payloads intact when a messaging tool sent to another route", () => { + expect( + resolveMessagingToolPayloadDedupe({ + messageProvider: "telegram", + originatingTo: "123", + messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], + }), + ).toEqual({ + shouldDedupePayloads: false, + suppressReplies: false, + }); + }); +}); diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 13f732ae188..21adc3fadce 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -8,5 +8,7 @@ export { export { filterMessagingToolDuplicates, filterMessagingToolMediaDuplicates, + resolveMessagingToolPayloadDedupe, shouldSuppressMessagingToolReplies, + type MessagingToolPayloadDedupeDecision, } from "./reply-payloads-dedupe.js";