From 576c6c240f48ab56a187163d7f91e28c79e53313 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 06:22:24 +0100 Subject: [PATCH] fix(discord): collapse cron announce text --- CHANGELOG.md | 1 + docs/automation/cron-jobs.md | 5 +++ docs/channels/discord.md | 3 ++ extensions/discord/src/channel.test.ts | 4 +++ extensions/discord/src/channel.ts | 1 + ...gent.direct-delivery-core-channels.test.ts | 34 ++++++++++++++++++- 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 109dddddf82..cf83d44dee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21. - Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai. - Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA. - Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 6f024f0f27a..7f549ee7e9f 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -94,6 +94,11 @@ When isolated cron runs orchestrate subagents, delivery also prefers the final descendant output over stale parent interim text. If descendants are still running, OpenClaw suppresses that partial parent update instead of announcing it. +For text-only Discord announce targets, OpenClaw sends the canonical final +assistant text once instead of replaying both streamed/intermediate text payloads +and the final answer. Media and structured Discord payloads are still delivered +as separate payloads so attachments and components are not dropped. + ### Payload options for isolated jobs - `--message`: prompt text (required for isolated) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index ca440706749..7b61c82e740 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -267,6 +267,9 @@ Now create some channels on your Discord server and start chatting. Your agent c - Guild channels are isolated session keys (`agent::discord:channel:`). - Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`). - Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session. +- Text-only cron/heartbeat announce delivery to Discord uses the final + assistant-visible answer once. Media and structured component payloads remain + multi-message when the agent emits multiple deliverable payloads. ## Forum channels diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index b02e0d64bc6..91499f1a0cf 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -115,6 +115,10 @@ describe("discordPlugin outbound", () => { expect(source).not.toContain('require("./channel-actions.js")'); }); + it("prefers final assistant text for text-only cron announce delivery", () => { + expect(discordPlugin.outbound?.preferFinalAssistantVisibleText).toBe(true); + }); + it("honors per-account replyToMode overrides", () => { const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode; if (!resolveReplyToMode) { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 3ccf4632f81..5f4b3603422 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -789,6 +789,7 @@ export const discordPlugin: ChannelPlugin }, outbound: { ...discordOutbound, + preferFinalAssistantVisibleText: true, shouldTreatDeliveredTextAsVisible: shouldTreatDiscordDeliveredTextAsVisible, shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) => shouldSuppressLocalDiscordExecApprovalPrompt({ diff --git a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts index 9fb54c41575..9a59c793c1c 100644 --- a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts +++ b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts @@ -157,10 +157,14 @@ function resolveCoreChannelSender( function createCliDelegatingOutbound(params: { channel: CoreChannel; deliveryMode?: ChannelOutboundAdapter["deliveryMode"]; + preferFinalAssistantVisibleText?: boolean; resolveTarget?: ChannelOutboundAdapter["resolveTarget"]; }): ChannelOutboundAdapter { return { deliveryMode: params.deliveryMode ?? "direct", + ...(params.preferFinalAssistantVisibleText !== undefined + ? { preferFinalAssistantVisibleText: params.preferFinalAssistantVisibleText } + : {}), ...(params.resolveTarget ? { resolveTarget: params.resolveTarget } : {}), sendText: async ({ cfg, to, text, accountId, deps }) => withRequiredMessageId( @@ -239,7 +243,10 @@ describe("runCronIsolatedAgentTurn core-channel direct delivery", () => { pluginId: "discord", plugin: createOutboundTestPlugin({ id: "discord", - outbound: createCliDelegatingOutbound({ channel: "discord" }), + outbound: createCliDelegatingOutbound({ + channel: "discord", + preferFinalAssistantVisibleText: true, + }), }), source: "test", }, @@ -283,6 +290,31 @@ describe("runCronIsolatedAgentTurn core-channel direct delivery", () => { }); }); + if (testCase.channel === "discord") { + it("collapses Discord text-only announce delivery to the final assistant text", async () => { + await expectCoreChannelAnnounceDelivery({ + testCase, + payloads: [{ text: "Working on it..." }, { text: "Final weather summary" }], + meta: { + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + finalAssistantVisibleText: "Final weather summary", + }, + }, + assertSend: (sendFn) => { + expect(sendFn).toHaveBeenCalledTimes(1); + expect(sendFn).toHaveBeenCalledWith( + testCase.expectedTo, + "Final weather summary", + expect.any(Object), + ); + }, + }); + }); + continue; + } + it(`preserves multi-payload text-only announce delivery for ${testCase.name} even when final assistant text exists`, async () => { await expectCoreChannelAnnounceDelivery({ testCase,