From 7d76c241f89c5fbd4eca173e002dc24c765e07ab Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 11:11:41 +0800 Subject: [PATCH] fix: suppress reasoning payloads from generic channel dispatch path When reasoningLevel is 'on', reasoning content was being sent as a visible message to WhatsApp and other non-Telegram channels via two paths: 1. Block reply: emitted via onBlockReply in handleMessageEnd 2. Final payloads: added to replyItems in buildEmbeddedRunPayloads Telegram has its own dispatch path (bot-message-dispatch.ts) that splits reasoning into a dedicated lane and handles suppression. The generic dispatch-from-config.ts path used by WhatsApp, web, etc. had no such filtering. Fix: - Add isReasoning?: boolean flag to ReplyPayload - Tag reasoning payloads at both emission points - Filter isReasoning payloads in dispatch-from-config.ts for both block reply and final reply paths Telegram is unaffected: it uses its own deliver callback that detects reasoning via the 'Reasoning:\n' prefix and routes to a separate lane. Fixes #24954 --- src/agents/pi-embedded-runner/run/payloads.ts | 2 +- ...pi-embedded-subscribe.handlers.messages.ts | 2 +- .../reply/dispatch-from-config.test.ts | 43 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 11 +++++ src/auto-reply/types.ts | 3 ++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 7b3d40c5d00..d4ee6dc0763 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -187,7 +187,7 @@ export function buildEmbeddedRunPayloads(params: { ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) { - replyItems.push({ text: reasoningText }); + replyItems.push({ text: reasoningText, isReasoning: true }); } const fallbackAnswerText = params.lastAssistant ? extractAssistantText(params.lastAssistant) : ""; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 845ded9f9b9..a32c9fdf219 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -339,7 +339,7 @@ export function handleMessageEnd( return; } ctx.state.lastReasoningSent = formattedReasoning; - void onBlockReply?.({ text: formattedReasoning }); + void onBlockReply?.({ text: formattedReasoning, isReasoning: true }); }; if (shouldEmitReasoningBeforeAnswer) { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 2a69f506a7f..bd1715bf511 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -538,4 +538,47 @@ describe("dispatchReplyFromConfig", () => { }), ); }); + + it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const replyResolver = async () => + [ + { text: "Reasoning:\n_thinking..._", isReasoning: true }, + { text: "The answer is 42" }, + ] satisfies ReplyPayload[]; + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; + expect(finalCalls).toHaveLength(1); + expect(finalCalls[0][0]).toMatchObject({ text: "The answer is 42" }); + }); + + it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const blockReplySentTexts: string[] = []; + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + ): Promise => { + // Simulate block reply with reasoning payload + await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true }); + await opts?.onBlockReply?.({ text: "The answer is 42" }); + return { text: "The answer is 42" }; + }; + // Capture what actually gets dispatched as block replies + (dispatcher.sendBlockReply as ReturnType).mockImplementation( + (payload: ReplyPayload) => { + if (payload.text) { + blockReplySentTexts.push(payload.text); + } + return true; + }, + ); + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._"); + expect(blockReplySentTexts).toContain("The answer is 42"); + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index e4e66c16a57..96989ff98ea 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -363,6 +363,12 @@ export async function dispatchReplyFromConfig(params: { }, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { + // Suppress reasoning payloads — channels using this generic dispatch + // path (WhatsApp, web, etc.) do not have a dedicated reasoning lane. + // Telegram has its own dispatch path that handles reasoning splitting. + if (payload.isReasoning) { + return; + } // Accumulate block text for TTS generation after streaming if (payload.text) { if (accumulatedBlockText.length > 0) { @@ -396,6 +402,11 @@ export async function dispatchReplyFromConfig(params: { let queuedFinal = false; let routedFinalCount = 0; for (const reply of replies) { + // Suppress reasoning payloads from channel delivery — channels using this + // generic dispatch path do not have a dedicated reasoning lane. + if (reply.isReasoning) { + continue; + } const ttsReply = await maybeApplyTtsToPayload({ payload: reply, cfg, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 839fac55977..f522e31042f 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -66,6 +66,9 @@ export type ReplyPayload = { /** Send audio as voice message (bubble) instead of audio file. Defaults to false. */ audioAsVoice?: boolean; isError?: boolean; + /** Marks this payload as a reasoning/thinking block. Channels that do not + * have a dedicated reasoning lane (e.g. WhatsApp, web) should suppress it. */ + isReasoning?: boolean; /** Channel-specific payload data (per-channel envelope). */ channelData?: Record; };