From 0342bed2899ee37b05d2b67ba9ede7b4473d6e2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 19:32:06 +0100 Subject: [PATCH] fix(replies): keep finals for cross-target messaging sends Co-authored-by: Ion Mudreac --- CHANGELOG.md | 1 + .../reply/agent-runner-payloads.test.ts | 28 +++++++++++++++++++ src/auto-reply/reply/agent-runner-payloads.ts | 26 +++++++++++------ .../agent-runner.misc.runreplyagent.test.ts | 13 +++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1568496ab..8e9b557e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai - Telegram/Network: default Node 22+ DNS result ordering to `ipv4first` for Telegram fetch paths and add `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`/`channels.telegram.network.dnsResultOrder` overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg. - Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov. - Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. +- Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies. - Telegram/Replies: extract forwarded-origin context from unified reply targets (`reply_to_message` and `external_reply`) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr. - Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower `update_id` updates after out-of-order completion. (#23284) thanks @frankekn. - Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic. diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index a82389695..88f7d41a4 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -43,4 +43,32 @@ describe("buildReplyPayloads media filter integration", () => { // Text filter removes the payload entirely (text matched), so nothing remains. expect(replyPayloads).toHaveLength(0); }); + + it("does not dedupe text for cross-target messaging sends", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello world!" }], + messageProvider: "telegram", + originatingTo: "telegram:123", + messagingToolSentTexts: ["hello world!"], + messagingToolSentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }], + }); + + expect(replyPayloads).toHaveLength(1); + expect(replyPayloads[0]?.text).toBe("hello world!"); + }); + + it("does not dedupe media for cross-target messaging sends", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "photo", mediaUrl: "file:///tmp/photo.jpg" }], + messageProvider: "telegram", + originatingTo: "telegram:123", + messagingToolSentMediaUrls: ["file:///tmp/photo.jpg"], + messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], + }); + + expect(replyPayloads).toHaveLength(1); + expect(replyPayloads[0]?.mediaUrl).toBe("file:///tmp/photo.jpg"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index ddc3bb0b1..a1de8c1d1 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -91,14 +91,24 @@ export function buildReplyPayloads(params: { originatingTo: params.originatingTo, accountId: params.accountId, }); - const dedupedPayloads = filterMessagingToolDuplicates({ - payloads: replyTaggedPayloads, - sentTexts: messagingToolSentTexts, - }); - const mediaFilteredPayloads = filterMessagingToolMediaDuplicates({ - payloads: dedupedPayloads, - sentMediaUrls: params.messagingToolSentMediaUrls ?? [], - }); + // 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 dedupedPayloads = dedupeMessagingToolPayloads + ? filterMessagingToolDuplicates({ + payloads: replyTaggedPayloads, + sentTexts: messagingToolSentTexts, + }) + : replyTaggedPayloads; + const mediaFilteredPayloads = dedupeMessagingToolPayloads + ? filterMessagingToolMediaDuplicates({ + payloads: dedupedPayloads, + sentMediaUrls: params.messagingToolSentMediaUrls ?? [], + }) + : dedupedPayloads; // Filter out payloads already sent via pipeline or directly during tool flush. const filteredPayloads = shouldDropFinalPayloads ? [] diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 66dac19a2..5d0465552 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -876,6 +876,19 @@ describe("runReplyAgent messaging tool suppression", () => { expect(result).toMatchObject({ text: "hello world!" }); }); + it("keeps final reply when text matches a cross-target messaging send", async () => { + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + messagingToolSentTexts: ["hello world!"], + messagingToolSentTargets: [{ tool: "discord", provider: "discord", to: "channel:C1" }], + meta: {}, + }); + + const result = await createRun("slack"); + + expect(result).toMatchObject({ text: "hello world!" }); + }); + it("delivers replies when account ids do not match", async () => { runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "hello world!" }],