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
This commit is contained in:
User
2026-02-24 11:11:41 +08:00
committed by Peter Steinberger
parent b9e587fb63
commit 7d76c241f8
5 changed files with 59 additions and 2 deletions

View File

@@ -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) : "";

View File

@@ -339,7 +339,7 @@ export function handleMessageEnd(
return;
}
ctx.state.lastReasoningSent = formattedReasoning;
void onBlockReply?.({ text: formattedReasoning });
void onBlockReply?.({ text: formattedReasoning, isReasoning: true });
};
if (shouldEmitReasoningBeforeAnswer) {

View File

@@ -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<typeof vi.fn>).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<ReplyPayload> => {
// 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<typeof vi.fn>).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");
});
});

View File

@@ -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,

View File

@@ -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<string, unknown>;
};