From 3b16a84e5a70baedd3ebe29ac27b69053ab396b0 Mon Sep 17 00:00:00 2001 From: vinay racha Date: Thu, 29 Jan 2026 23:38:56 +0530 Subject: [PATCH] fix(telegram): preserve reasoning tags inside code blocks (#3952) --- src/shared/text/reasoning-tags.test.ts | 122 +++++++++++++++++++++++++ src/shared/text/reasoning-tags.ts | 49 +++++++++- 2 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 src/shared/text/reasoning-tags.test.ts diff --git a/src/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts new file mode 100644 index 000000000..394a914fa --- /dev/null +++ b/src/shared/text/reasoning-tags.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { stripReasoningTagsFromText } from "./reasoning-tags.js"; + +describe("stripReasoningTagsFromText", () => { + describe("basic functionality", () => { + it("returns text unchanged when no reasoning tags present", () => { + const input = "Hello, this is a normal message."; + expect(stripReasoningTagsFromText(input)).toBe(input); + }); + + it("strips proper think tags", () => { + const input = "Hello internal reasoning world!"; + expect(stripReasoningTagsFromText(input)).toBe("Hello world!"); + }); + + it("strips thinking tags", () => { + const input = "Before some thought after"; + expect(stripReasoningTagsFromText(input)).toBe("Before after"); + }); + + it("strips thought tags", () => { + const input = "A hmm B"; + expect(stripReasoningTagsFromText(input)).toBe("A B"); + }); + + it("strips antthinking tags", () => { + const input = "X internal Y"; + expect(stripReasoningTagsFromText(input)).toBe("X Y"); + }); + + it("strips multiple reasoning blocks", () => { + const input = "firstAsecondB"; + expect(stripReasoningTagsFromText(input)).toBe("AB"); + }); + }); + + describe("code block preservation (issue #3952)", () => { + it("preserves think tags inside fenced code blocks", () => { + const input = "Use the tag like this:\n```\nreasoning\n```\nThat's it!"; + expect(stripReasoningTagsFromText(input)).toBe(input); + }); + + it("preserves think tags inside inline code", () => { + const input = + "The `` tag is used for reasoning. Don't forget the closing `` tag."; + expect(stripReasoningTagsFromText(input)).toBe(input); + }); + + it("preserves tags in fenced code blocks with language specifier", () => { + const input = "Example:\n```xml\n\n nested\n\n```\nDone!"; + expect(stripReasoningTagsFromText(input)).toBe(input); + }); + + it("handles mixed real tags and code tags", () => { + const input = "hiddenVisible text with `` example."; + expect(stripReasoningTagsFromText(input)).toBe("Visible text with `` example."); + }); + + it("preserves both opening and closing tags in backticks", () => { + const input = "Use `` to open and `` to close."; + expect(stripReasoningTagsFromText(input)).toBe(input); + }); + }); + + describe("edge cases", () => { + it("preserves unclosed { + const input = "Here is how to use { + const input = "You can start with "; + expect(stripReasoningTagsFromText(input)).toBe( + "You can start with { + const input = "A < think >content< /think > B"; + expect(stripReasoningTagsFromText(input)).toBe("A B"); + }); + + it("handles empty input", () => { + expect(stripReasoningTagsFromText("")).toBe(""); + }); + + it("handles null-ish input", () => { + expect(stripReasoningTagsFromText(null as unknown as string)).toBe(null); + }); + }); + + describe("strict vs preserve mode", () => { + it("strict mode truncates on unclosed tag", () => { + const input = "Before unclosed content after"; + expect(stripReasoningTagsFromText(input, { mode: "strict" })).toBe("Before"); + }); + + it("preserve mode keeps content after unclosed tag", () => { + const input = "Before unclosed content after"; + expect(stripReasoningTagsFromText(input, { mode: "preserve" })).toBe( + "Before unclosed content after", + ); + }); + }); + + describe("trim options", () => { + it("trims both sides by default", () => { + const input = " x result y "; + expect(stripReasoningTagsFromText(input)).toBe("result"); + }); + + it("trim=none preserves whitespace", () => { + const input = " x result "; + expect(stripReasoningTagsFromText(input, { trim: "none" })).toBe(" result "); + }); + + it("trim=start only trims start", () => { + const input = " x result "; + expect(stripReasoningTagsFromText(input, { trim: "start" })).toBe("result "); + }); + }); +}); diff --git a/src/shared/text/reasoning-tags.ts b/src/shared/text/reasoning-tags.ts index 822138e55..6bba914b9 100644 --- a/src/shared/text/reasoning-tags.ts +++ b/src/shared/text/reasoning-tags.ts @@ -2,8 +2,40 @@ export type ReasoningTagMode = "strict" | "preserve"; export type ReasoningTagTrim = "none" | "start" | "both"; const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking|final)\b/i; -const FINAL_TAG_RE = /<\s*\/?\s*final\b[^>]*>/gi; -const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi; +const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/gi; +const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi; + +interface CodeRegion { + start: number; + end: number; +} + +function findCodeRegions(text: string): CodeRegion[] { + const regions: CodeRegion[] = []; + + const fencedRe = /(^|\n)(```|~~~).*?\n[\s\S]*?\n\2/g; + for (const match of text.matchAll(fencedRe)) { + const start = (match.index ?? 0) + match[1].length; + regions.push({ start, end: start + match[0].length - match[1].length }); + } + + const inlineRe = /`+[^`]+`+/g; + for (const match of text.matchAll(inlineRe)) { + const start = match.index ?? 0; + const end = start + match[0].length; + const insideFenced = regions.some((r) => start >= r.start && end <= r.end); + if (!insideFenced) { + regions.push({ start, end }); + } + } + + regions.sort((a, b) => a.start - b.start); + return regions; +} + +function isInsideCode(pos: number, regions: CodeRegion[]): boolean { + return regions.some((r) => pos >= r.start && pos < r.end); +} function applyTrim(value: string, mode: ReasoningTagTrim): string { if (mode === "none") return value; @@ -24,14 +56,21 @@ export function stripReasoningTagsFromText( const mode = options?.mode ?? "strict"; const trimMode = options?.trim ?? "both"; + const codeRegions = findCodeRegions(text); + let cleaned = text; if (FINAL_TAG_RE.test(cleaned)) { FINAL_TAG_RE.lastIndex = 0; - cleaned = cleaned.replace(FINAL_TAG_RE, ""); + cleaned = cleaned.replace(FINAL_TAG_RE, (match, offset) => { + if (isInsideCode(offset, codeRegions)) return match; + return ""; + }); } else { FINAL_TAG_RE.lastIndex = 0; } + const updatedCodeRegions = findCodeRegions(cleaned); + THINKING_TAG_RE.lastIndex = 0; let result = ""; let lastIndex = 0; @@ -41,6 +80,10 @@ export function stripReasoningTagsFromText( const idx = match.index ?? 0; const isClose = match[1] === "/"; + if (isInsideCode(idx, updatedCodeRegions)) { + continue; + } + if (!inThinking) { result += cleaned.slice(lastIndex, idx); if (!isClose) {