From d65d1b166ffcce2582f798e790ff61cb81cbc81d Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:31:31 -0600 Subject: [PATCH] fix(discord): normalize tagged reasoning in user-visible replies --- CHANGELOG.md | 1 + src/agents/tools/agent-step.test.ts | 20 ++++++++++++++ .../monitor/message-handler.process.test.ts | 18 +++++++++++++ .../monitor/message-handler.process.ts | 26 +++++++++++++++---- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1d0dfe4003..42610aeb5f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/reply normalization parity: strip `/` reasoning tags from final/block outbound payload text before preview-finalization and delivery so user-visible replies stay aligned with cleaned final-answer policy even when transcripts contain raw tagged content. (#38291) - Onboarding/headless Linux daemon probe hardening: treat `systemctl --user is-enabled` probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web. - Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i. - Tools/web_search Brave language-code validation: align `search_lang` handling with Brave-supported codes (including `zh-hans`, `zh-hant`, `en-gb`, and `pt-br`), map common alias inputs (`zh`, `ja`) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming. diff --git a/src/agents/tools/agent-step.test.ts b/src/agents/tools/agent-step.test.ts index 2ba291c325d..283aed6440d 100644 --- a/src/agents/tools/agent-step.test.ts +++ b/src/agents/tools/agent-step.test.ts @@ -46,4 +46,24 @@ describe("readLatestAssistantReply", () => { expect(result).toBe("older output"); }); + + it("normalizes reasoning tags when reading from transcript history", async () => { + callGatewayMock.mockResolvedValue({ + messages: [ + { + role: "assistant", + content: [ + { + type: "text", + text: "private reasoningClean answer", + }, + ], + }, + ], + }); + + const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" }); + + expect(result).toBe("Clean answer"); + }); }); diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 9bc9cf77498..4c80cb2ea02 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -527,6 +527,24 @@ describe("processDiscordMessage draft streaming", () => { expect(editMessageDiscord).not.toHaveBeenCalled(); }); + it("strips reasoning tags from final payload delivery", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ + text: "internal chain of thoughtVisible answer", + }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + await processStreamOffDiscordMessage(); + + expect(deliverDiscordReply).toHaveBeenCalledTimes(1); + expect(deliverDiscordReply).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "Visible answer" })], + }), + ); + }); + it("delivers non-reasoning block payloads to Discord", async () => { mockDispatchSingleBlockReply({ text: "hello from block stream" }); await processStreamOffDiscordMessage(); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 1fb0e8590c1..3b6d3c99e9e 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -592,6 +592,17 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) await draftStream.flush(); }; + const sanitizeVisibleReplyText = (text?: string) => { + if (typeof text !== "string") { + return text; + } + const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); + if (cleaned.startsWith("Reasoning:\n")) { + return ""; + } + return cleaned; + }; + // When draft streaming is active, suppress block streaming to avoid double-streaming. const disableBlockStreamingForDraft = draftStream ? true : undefined; @@ -609,10 +620,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) // Reasoning/thinking payloads should not be delivered to Discord. return; } + const visiblePayload = + typeof payload.text === "string" + ? { ...payload, text: sanitizeVisibleReplyText(payload.text) } + : payload; if (draftStream && isFinal) { await flushDraft(); - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const finalText = payload.text; + const hasMedia = + Boolean(visiblePayload.mediaUrl) || (visiblePayload.mediaUrls?.length ?? 0) > 0; + const finalText = visiblePayload.text; const previewFinalText = resolvePreviewFinalText(finalText); const previewMessageId = draftStream.messageId(); @@ -622,7 +638,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) !hasMedia && typeof previewFinalText === "string" && typeof previewMessageId === "string" && - !payload.isError; + !visiblePayload.isError; if (canFinalizeViaPreviewEdit) { await draftStream.stop(); @@ -657,7 +673,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) typeof messageIdAfterStop === "string" && typeof previewFinalText === "string" && !hasMedia && - !payload.isError + !visiblePayload.isError ) { try { await editMessageDiscord( @@ -688,7 +704,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const replyToId = replyReference.use(); await deliverDiscordReply({ - replies: [payload], + replies: [visiblePayload], target: deliverTarget, token, accountId,