fix(agents): dedupe messaging tool replies by route

This commit is contained in:
Peter Steinberger
2026-05-01 12:34:29 +01:00
parent e073485c23
commit 72f6016ce5
7 changed files with 133 additions and 35 deletions

View File

@@ -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);

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -1,5 +1,7 @@
export {
filterMessagingToolDuplicates,
filterMessagingToolMediaDuplicates,
resolveMessagingToolPayloadDedupe,
shouldSuppressMessagingToolReplies,
type MessagingToolPayloadDedupeDecision,
} from "./reply-payloads-dedupe.js";

View File

@@ -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,
};
}

View File

@@ -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,
});
});
});

View File

@@ -8,5 +8,7 @@ export {
export {
filterMessagingToolDuplicates,
filterMessagingToolMediaDuplicates,
resolveMessagingToolPayloadDedupe,
shouldSuppressMessagingToolReplies,
type MessagingToolPayloadDedupeDecision,
} from "./reply-payloads-dedupe.js";