diff --git a/src/gateway/server-methods/chat-webchat-media.test.ts b/src/gateway/server-methods/chat-webchat-media.test.ts index 67de5784ede..ae41dd9bed2 100644 --- a/src/gateway/server-methods/chat-webchat-media.test.ts +++ b/src/gateway/server-methods/chat-webchat-media.test.ts @@ -43,6 +43,26 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => { ); }); + it("suppresses reasoning payload audio", async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-")); + const audioPath = path.join(tmpDir, "clip.mp3"); + fs.writeFileSync(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); + + const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads( + [ + { + text: "Reasoning:\n_step_", + mediaUrl: audioPath, + trustedLocalMedia: true, + isReasoning: true, + }, + ], + { localRoots: [tmpDir] }, + ); + + expect(blocks).toHaveLength(0); + }); + it("skips remote URLs", async () => { const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ { mediaUrl: "https://example.com/a.mp3", trustedLocalMedia: true }, @@ -212,6 +232,18 @@ describe("buildWebchatAssistantMessageFromReplyPayloads", () => { }); }); + it("suppresses reasoning payload media transcripts", async () => { + const message = await buildWebchatAssistantMessageFromReplyPayloads([ + { + text: "Reasoning:\n_step_", + mediaUrl: "data:image/png;base64,cG5n", + isReasoning: true, + }, + ]); + + expect(message).toBeNull(); + }); + it("suppresses control tokens and falls back to synthetic image text", async () => { const message = await buildWebchatAssistantMessageFromReplyPayloads([ { diff --git a/src/gateway/server-methods/chat-webchat-media.ts b/src/gateway/server-methods/chat-webchat-media.ts index 06a88fa8939..e1b76902c36 100644 --- a/src/gateway/server-methods/chat-webchat-media.ts +++ b/src/gateway/server-methods/chat-webchat-media.ts @@ -162,6 +162,9 @@ export async function buildWebchatAudioContentBlocksFromReplyPayloads( const seen = new Set(); const blocks: Array> = []; for (const payload of payloads) { + if (payload.isReasoning === true) { + continue; + } const parts = resolveSendableOutboundReplyParts(payload); for (const raw of parts.mediaUrls) { const url = raw.trim(); @@ -194,6 +197,9 @@ export async function buildWebchatAssistantMessageFromReplyPayloads( let hasImage = false; for (const payload of payloads) { + if (payload.isReasoning === true) { + continue; + } const visibleText = payload.text?.trim(); const text = visibleText && !isSuppressedControlReplyText(visibleText) ? visibleText : undefined; diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 7fd3f213c23..ed78955c645 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -26,6 +26,7 @@ const mockState = vi.hoisted(() => ({ sensitiveMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; } | null, dispatchedReplies: [] as Array<{ kind: "tool" | "block" | "final"; @@ -36,6 +37,7 @@ const mockState = vi.hoisted(() => ({ trustedLocalMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }; }>, dispatchError: null as Error | null, @@ -114,6 +116,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ sensitiveMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }) => boolean; sendBlockReply: (payload: { text?: string; @@ -122,6 +125,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ trustedLocalMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }) => boolean; sendToolResult: (payload: { text?: string; @@ -130,6 +134,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ trustedLocalMedia?: boolean; replyToId?: string; replyToCurrent?: boolean; + isReasoning?: boolean; }) => boolean; markComplete: () => void; waitForIdle: () => Promise; @@ -599,6 +604,31 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(JSON.stringify(payload?.message)).not.toContain("MEDIA:data:image/png;base64,cG5n"); }); + it("suppresses reasoning payloads from webchat transcript replies", async () => { + createTranscriptFixture("openclaw-chat-send-reasoning-hidden-"); + mockState.dispatchedReplies = [ + { + kind: "final", + payload: { text: "Reasoning:\n_step_", isReasoning: true }, + }, + { + kind: "final", + payload: { text: "final answer" }, + }, + ]; + const respond = vi.fn(); + const context = createChatContext(); + + const payload = await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-reasoning-hidden", + }); + + expect(JSON.stringify(payload?.message)).toContain("final answer"); + expect(JSON.stringify(payload?.message)).not.toContain("Reasoning"); + }); + it("chat.inject keeps message defined when directive tag is the only content", async () => { createTranscriptFixture("openclaw-chat-inject-directive-only-"); const respond = vi.fn(); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1af9302ddd5..4f1d0769b92 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -131,6 +131,9 @@ type ChatAbortRequester = { /** True when a reply payload carries at least one media reference (mediaUrl or mediaUrls). */ function isMediaBearingPayload(payload: ReplyPayload): boolean { + if (payload.isReasoning === true) { + return false; + } if (payload.mediaUrl?.trim()) { return true; } @@ -227,6 +230,9 @@ type SideResultPayload = { function buildTranscriptReplyText(payloads: ReplyPayload[]): string { const chunks = payloads .map((payload) => { + if (payload.isReasoning === true) { + return ""; + } const parts = resolveSendableOutboundReplyParts(payload); const lines: string[] = []; const replyToId = sanitizeReplyDirectiveId(payload.replyToId); @@ -301,7 +307,10 @@ async function buildAssistantDisplayContentFromReplyPayloads(params: { onManagedImagePrepareError?: (message: string) => void; }): Promise { const rawTextPayloadCount = params.payloads.filter( - (payload) => typeof payload.text === "string" && payload.text.trim().length > 0, + (payload) => + payload.isReasoning !== true && + typeof payload.text === "string" && + payload.text.trim().length > 0, ).length; const normalized = normalizeReplyPayloadsForDelivery(params.payloads); if (normalized.length === 0) {