From f70d5b8ba5881346e7d9f76ea2c8c5cc5ae59ca6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 13 Aug 2026 20:36:48 +0530 Subject: [PATCH] fix(reasoning-tags): prevent internal reflections in replies (#123196) Treat blocks as private reasoning in the shared parser and remove Telegram raw-reasoning fallbacks. This keeps model reflections out of user-visible replies while preserving surrounding answer text. Closes #122623 Co-authored-by: Ayaan Zaidi Co-authored-by: WangYan --- .../src/bot-message-dispatch-draft.ts | 3 +- ...age-dispatch.reasoning-room-events.test.ts | 18 +++++++ .../src/reasoning-lane-coordinator.test.ts | 5 ++ .../src/reasoning-lane-coordinator.ts | 10 ++-- .../reasoning-tag-text-partitioner.test.ts | 14 ++++++ .../markdown-core/src/reasoning-tag-parser.ts | 50 ++++++++++++------- src/agents/tools/message-tool.test.ts | 7 +++ .../text/assistant-visible-text.test.ts | 11 ++++ src/shared/text/reasoning-tags.test.ts | 20 ++++++++ 9 files changed, 116 insertions(+), 22 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch-draft.ts b/extensions/telegram/src/bot-message-dispatch-draft.ts index 5b53d62d5faa..f50f9c7d2630 100644 --- a/extensions/telegram/src/bot-message-dispatch-draft.ts +++ b/extensions/telegram/src/bot-message-dispatch-draft.ts @@ -326,7 +326,8 @@ export function splitTextIntoLaneSegments( ...(update.isReasoningSnapshot ? { isReasoningSnapshot: true } : {}), }, })), - suppressedReasoningOnly: Boolean(split.reasoningText) && suppressReasoning && !split.answerText, + suppressedReasoningOnly: + isReasoning === true && !split.answerText && (suppressReasoning || !split.reasoningText), }; } diff --git a/extensions/telegram/src/bot-message-dispatch.reasoning-room-events.test.ts b/extensions/telegram/src/bot-message-dispatch.reasoning-room-events.test.ts index f536bd59baff..0e4273a074ae 100644 --- a/extensions/telegram/src/bot-message-dispatch.reasoning-room-events.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.reasoning-room-events.test.ts @@ -188,6 +188,24 @@ describeTelegramDispatch("dispatchTelegramMessage reasoning-room-events", () => expect(deliverReplies).not.toHaveBeenCalled(); }); + it("suppresses internal reflection when reasoning streams", async () => { + const { reasoningDraftStream } = setupDraftStreams({ + answerMessageId: 2001, + reasoningMessageId: 3001, + }); + mockTurn(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { text: "private reflection", isReasoning: true }, + { kind: "final" }, + ); + }); + + await dispatchWithContext({ context: createReasoningStreamContext() }); + + expect(reasoningDraftStream.update).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it("routes typed reasoning-only finals to durable delivery when reasoning is persistent", async () => { loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" }, diff --git a/extensions/telegram/src/reasoning-lane-coordinator.test.ts b/extensions/telegram/src/reasoning-lane-coordinator.test.ts index ff9d7feffa4b..f60aaaf0aec3 100644 --- a/extensions/telegram/src/reasoning-lane-coordinator.test.ts +++ b/extensions/telegram/src/reasoning-lane-coordinator.test.ts @@ -23,6 +23,10 @@ describe("splitTelegramReasoningText", () => { }); }); + it("suppresses internal reflection from explicitly typed reasoning", () => { + expect(splitTelegramReasoningText("private reflection", true)).toEqual({}); + }); + it("ignores literal think tags inside inline code", () => { const text = "Use `example` literally."; expect(splitTelegramReasoningText(text)).toEqual({ @@ -39,6 +43,7 @@ describe("splitTelegramReasoningText", () => { it("does not emit partial reasoning tag prefixes", () => { expect(splitTelegramReasoningText(" { diff --git a/extensions/telegram/src/reasoning-lane-coordinator.ts b/extensions/telegram/src/reasoning-lane-coordinator.ts index c4dab04509a7..e71657cf6e14 100644 --- a/extensions/telegram/src/reasoning-lane-coordinator.ts +++ b/extensions/telegram/src/reasoning-lane-coordinator.ts @@ -29,11 +29,13 @@ const REASONING_TAG_PREFIXES = [ " { expect(partitioner.pushVisible("outerinner")).toEqual([]); expect(partitioner.flush()).toEqual([{ kind: "thinking", text: "outerinner" }]); }); + + it("never emits nested unclosed internal reflection on flush", () => { + const partitioner = createReasoningTagTextPartitioner(); + + expect(partitioner.pushVisible("outerprivate reflection")).toEqual([]); + expect(partitioner.flush()).toEqual([]); + }); + + it("never emits closed internal reflection", () => { + const partitioner = createReasoningTagTextPartitioner(); + + expect(partitioner.pushVisible("private reflection")).toEqual([]); + expect(partitioner.flush()).toEqual([]); + }); }); diff --git a/packages/markdown-core/src/reasoning-tag-parser.ts b/packages/markdown-core/src/reasoning-tag-parser.ts index 8654b2cba418..5004b36e22a1 100644 --- a/packages/markdown-core/src/reasoning-tag-parser.ts +++ b/packages/markdown-core/src/reasoning-tag-parser.ts @@ -12,6 +12,7 @@ export const REASONING_TAG_NAMES = [ "thinking", "thought", "reasoning", + "internal", "antthinking", "antml:think", "antml:thinking", @@ -32,6 +33,7 @@ type ReasoningTagMatch = { text: string; isClose: boolean; isSelfClosing: boolean; + isPrivate: boolean; }; type ReasoningTagScan = { @@ -127,6 +129,7 @@ export function parseReasoningTagAt( text: text.slice(start, end), isClose, isSelfClosing: !isClose && lastSignificant === "/", + isPrivate: partialName === "internal", }, }; } @@ -252,6 +255,7 @@ export type ReductionState = { visibleEver: boolean; pending?: { content: string; + containsPrivate: boolean; openTag: string; protectedClose: boolean; visibleBefore: boolean; @@ -306,18 +310,20 @@ export function reduceReasoningText( index: scannedTag.index + start, isClose: scannedTag.isClose, isSelfClosing: scannedTag.isSelfClosing, + isPrivate: scannedTag.isPrivate, text: scannedTag.text, }; if (!isInsideCode(tag.index, codeSpans)) { tags.push(tag); } } - const hasCloseAfter: boolean[] = []; + const mustParseRemainder: boolean[] = []; if (options.scope === "leading") { - let seenClose = false; + let mustParse = false; for (let index = tags.length - 1; index >= 0; index -= 1) { - hasCloseAfter[index] = seenClose; - seenClose ||= tags[index]?.isClose === true; + mustParse ||= tags[index]?.isPrivate === true; + mustParseRemainder[index] = mustParse; + mustParse ||= tags[index]?.isClose === true; } } let cursor = start; @@ -346,7 +352,7 @@ export function reduceReasoningText( state.depth === 0 && options.scope === "leading" && state.visibleEver && - !hasCloseAfter[tagIndex] + !mustParseRemainder[tagIndex] ) { emit("text", text.slice(tag.index)); cursor = text.length; @@ -355,10 +361,14 @@ export function reduceReasoningText( if (state.depth === 0) { state.pending = { content: "", + containsPrivate: tag.isPrivate, openTag: tag.text, protectedClose: false, visibleBefore: state.visibleEver, }; + } else if (state.pending) { + // A nested private block makes the enclosing reasoning non-emitting. + state.pending.containsPrivate ||= tag.isPrivate; } state.depth += 1; cursor = tagEnd; @@ -368,7 +378,9 @@ export function reduceReasoningText( if (state.depth > 0) { state.depth -= 1; if (state.depth === 0 && state.pending) { - emit("thinking", state.pending.content); + if (!state.pending.containsPrivate) { + emit("thinking", state.pending.content); + } state.pending = undefined; } else if (state.pending) { state.pending.protectedClose = true; @@ -393,18 +405,20 @@ export function reduceReasoningText( append(text.slice(cursor)); if (options.final && state.depth > 0 && state.pending) { const pending = state.pending; - const recoverAsText = - options.mode === "static-preserve" || - (options.mode === "static-strict" && !pending.visibleBefore && !pending.protectedClose) || - (options.mode === "visible" && !pending.protectedClose); - if (recoverAsText) { - const value = - options.mode === "visible" && pending.visibleBefore - ? pending.openTag + pending.content - : pending.content; - emit("text", value); - } else { - emit("thinking", pending.content); + if (!pending.containsPrivate) { + const recoverAsText = + options.mode === "static-preserve" || + (options.mode === "static-strict" && !pending.visibleBefore && !pending.protectedClose) || + (options.mode === "visible" && !pending.protectedClose); + if (recoverAsText) { + const value = + options.mode === "visible" && pending.visibleBefore + ? pending.openTag + pending.content + : pending.content; + emit("text", value); + } else { + emit("thinking", pending.content); + } } state.depth = 0; state.pending = undefined; diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index fb9f4903aede..f0d96b842ee4 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -3806,6 +3806,13 @@ describe("message tool reasoning tag sanitization", () => { target: "telegram:123", channel: "telegram", }, + { + field: "message", + input: "private reflectionVisible answer", + expected: "Visible answer", + target: "telegram:123", + channel: "telegram", + }, { field: "message", input: "Thinking\n_internal plan_\n\nVisible answer", diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index 501b6441a5be..63cb07008113 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -38,6 +38,11 @@ describe("stripAssistantInternalScaffolding", () => { input: ["", "secret", "", "Visible"].join("\n"), expected: "Visible", }, + { + name: "strips internal reflection tags", + input: ["", "private reflection", "", "Visible"].join("\n"), + expected: "Visible", + }, { name: "strips relevant-memories scaffolding blocks", input: [ @@ -986,6 +991,12 @@ describe("sanitizeAssistantVisibleText", () => { "Before literal tag text after", ); }); + + it("never recovers unclosed internal reflection from final-answer prose", () => { + expect( + sanitizeAssistantFinalAnswerText("Visible prefix private reflection"), + ).toBe("Visible prefix"); + }); }); describe("sanitizeAssistantVisibleTextWithProfile", () => { diff --git a/src/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts index a78a7babb2d6..f9afb22ea55c 100644 --- a/src/shared/text/reasoning-tags.test.ts +++ b/src/shared/text/reasoning-tags.test.ts @@ -81,6 +81,16 @@ describe("stripReasoningTagsFromText", () => { input: "firstAsecondB", expected: "AB", }, + { + name: "strips internal reflection blocks", + input: "private reflectionVisible answer.", + expected: "Visible answer.", + }, + { + name: "never recovers nested unclosed internal reflection as visible text", + input: "outerprivate reflection", + expected: "", + }, ] as const)("$name", (testCase) => { expectStrippedCase(testCase); }); @@ -96,6 +106,10 @@ describe("stripReasoningTagsFromText", () => { name: "preserves inline literal think tag documentation", input: "The `` tag is used for reasoning. Don't forget the closing `` tag.", }, + { + name: "preserves literal internal tag documentation", + input: "Use `private` literally.", + }, { name: "preserves xml fenced examples", input: "Example:\n```xml\n\n nested\n\n```\nDone!", @@ -370,6 +384,12 @@ describe("stripReasoningTagsFromText", () => { expected: "A B", opts: { mode: "preserve" as const }, }, + { + name: "does not recover internal reflection in preserve mode", + input: "private reflection", + expected: "", + opts: { mode: "preserve" as const }, + }, ] as const)("$name", (testCase) => { expectStrippedCase(testCase); });