fix(reasoning-tags): prevent internal reflections in replies (#123196)

Treat <internal> 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 <hi@obviy.us>
Co-authored-by: WangYan <wang.yan29@xydigit.com>
This commit is contained in:
Ayaan Zaidi
2026-08-13 20:36:48 +05:30
committed by GitHub
parent 0e5855be8b
commit f70d5b8ba5
9 changed files with 116 additions and 22 deletions
@@ -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),
};
}
@@ -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: "<internal>private reflection</internal>", 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" },
@@ -23,6 +23,10 @@ describe("splitTelegramReasoningText", () => {
});
});
it("suppresses internal reflection from explicitly typed reasoning", () => {
expect(splitTelegramReasoningText("<internal>private reflection</internal>", true)).toEqual({});
});
it("ignores literal think tags inside inline code", () => {
const text = "Use `<think>example</think>` literally.";
expect(splitTelegramReasoningText(text)).toEqual({
@@ -39,6 +43,7 @@ describe("splitTelegramReasoningText", () => {
it("does not emit partial reasoning tag prefixes", () => {
expect(splitTelegramReasoningText(" <thi", true)).toStrictEqual({});
expect(splitTelegramReasoningText(" <int", true)).toStrictEqual({});
});
it("keeps visible Thinking-prefixed answers in the answer lane", () => {
@@ -29,11 +29,13 @@ const REASONING_TAG_PREFIXES = [
"<think",
"<thinking",
"<thought",
"<internal",
"<antthinking",
"<mm:think",
"</think",
"</thinking",
"</thought",
"</internal",
"</antthinking",
"</mm:think",
];
@@ -116,11 +118,13 @@ export function splitTelegramReasoningText(
const taggedReasoning = extractThinkingFromTaggedStreamOutsideCode(text);
const strippedAnswer = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
const reasoningText = taggedReasoning || strippedAnswer;
if (!reasoningText) {
return {};
}
return {
reasoningText: markReasoningMessage(
formatReasoningMessage(taggedReasoning || strippedAnswer || text),
),
reasoningText: markReasoningMessage(formatReasoningMessage(reasoningText)),
};
}
@@ -736,4 +736,18 @@ describe("createReasoningTagTextPartitioner", () => {
expect(partitioner.pushVisible("<think>outer<think>inner</think>")).toEqual([]);
expect(partitioner.flush()).toEqual([{ kind: "thinking", text: "outerinner" }]);
});
it("never emits nested unclosed internal reflection on flush", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("<thinking>outer<internal>private reflection")).toEqual([]);
expect(partitioner.flush()).toEqual([]);
});
it("never emits closed internal reflection", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("<internal>private reflection</internal>")).toEqual([]);
expect(partitioner.flush()).toEqual([]);
});
});
@@ -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;
+7
View File
@@ -3806,6 +3806,13 @@ describe("message tool reasoning tag sanitization", () => {
target: "telegram:123",
channel: "telegram",
},
{
field: "message",
input: "<internal>private reflection</internal>Visible answer",
expected: "Visible answer",
target: "telegram:123",
channel: "telegram",
},
{
field: "message",
input: "Thinking\n_internal plan_\n\nVisible answer",
@@ -38,6 +38,11 @@ describe("stripAssistantInternalScaffolding", () => {
input: ["<thinking>", "secret", "</thinking>", "Visible"].join("\n"),
expected: "Visible",
},
{
name: "strips internal reflection tags",
input: ["<internal>", "private reflection", "</internal>", "Visible"].join("\n"),
expected: "Visible",
},
{
name: "strips relevant-memories scaffolding blocks",
input: [
@@ -986,6 +991,12 @@ describe("sanitizeAssistantVisibleText", () => {
"Before <think>literal tag text after",
);
});
it("never recovers unclosed internal reflection from final-answer prose", () => {
expect(
sanitizeAssistantFinalAnswerText("Visible prefix <thinking><internal>private reflection"),
).toBe("Visible prefix");
});
});
describe("sanitizeAssistantVisibleTextWithProfile", () => {
+20
View File
@@ -81,6 +81,16 @@ describe("stripReasoningTagsFromText", () => {
input: "<think>first</think>A<think>second</think>B",
expected: "AB",
},
{
name: "strips internal reflection blocks",
input: "<internal>private reflection</internal>Visible answer.",
expected: "Visible answer.",
},
{
name: "never recovers nested unclosed internal reflection as visible text",
input: "<thinking>outer<internal>private reflection",
expected: "",
},
] as const)("$name", (testCase) => {
expectStrippedCase(testCase);
});
@@ -96,6 +106,10 @@ describe("stripReasoningTagsFromText", () => {
name: "preserves inline literal think tag documentation",
input: "The `<think>` tag is used for reasoning. Don't forget the closing `</think>` tag.",
},
{
name: "preserves literal internal tag documentation",
input: "Use `<internal>private</internal>` literally.",
},
{
name: "preserves xml fenced examples",
input: "Example:\n```xml\n<think>\n <thought>nested</thought>\n</think>\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: "<internal>private reflection",
expected: "",
opts: { mode: "preserve" as const },
},
] as const)("$name", (testCase) => {
expectStrippedCase(testCase);
});