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);
});