diff --git a/CHANGELOG.md b/CHANGELOG.md index c274f668a..570878ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot ### Fixes - Web UI: hide internal `message_id` hints in chat bubbles. - Heartbeat: normalize target identifiers for consistent routing. +- TUI: unify reasoning tag stripping so `` wrappers stay hidden. (#1613) Thanks @kyleok. ## 2026.1.23-1 diff --git a/src/agents/pi-embedded-utils.test.ts b/src/agents/pi-embedded-utils.test.ts index 92717a2a7..c765a4d3a 100644 --- a/src/agents/pi-embedded-utils.test.ts +++ b/src/agents/pi-embedded-utils.test.ts @@ -460,6 +460,22 @@ File contents here`, expect(result).toBe("The actual answer."); }); + it("strips final tags while keeping content", () => { + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: "\nAnswer\n", + }, + ], + timestamp: Date.now(), + }; + + const result = extractAssistantText(msg); + expect(result).toBe("Answer"); + }); + it("strips thought tags", () => { const msg: AssistantMessage = { role: "assistant", diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index 1a392c2f1..89a9df805 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -1,4 +1,5 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js"; import { sanitizeUserFacingText } from "./pi-embedded-helpers.js"; import { formatToolDetail, resolveToolDisplay } from "./tool-display.js"; @@ -166,36 +167,7 @@ export function stripDowngradedToolCallText(text: string): string { * that slip through other filtering mechanisms. */ export function stripThinkingTagsFromText(text: string): string { - if (!text) return text; - // Quick check to avoid regex overhead when no tags present. - if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text; - - const tagRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi; - let result = ""; - let lastIndex = 0; - let inThinking = false; - - for (const match of text.matchAll(tagRe)) { - const idx = match.index ?? 0; - const isClose = match[1] === "/"; - - if (!inThinking && !isClose) { - // Opening tag - save text before it. - result += text.slice(lastIndex, idx); - inThinking = true; - } else if (inThinking && isClose) { - // Closing tag - skip content inside. - inThinking = false; - } - lastIndex = idx + match[0].length; - } - - // Append remaining text if we're not inside thinking. - if (!inThinking) { - result += text.slice(lastIndex); - } - - return result.trim(); + return stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); } export function extractAssistantText(msg: AssistantMessage): string { diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 37de9adab..028e657cb 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -67,9 +67,10 @@ export function normalizeAgentId(value: string | undefined | null): string { export function sanitizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) return DEFAULT_AGENT_ID; - if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); return ( trimmed + .toLowerCase() .replace(/[^a-z0-9_-]+/gi, "-") .replace(/^-+/, "") .replace(/-+$/, "") diff --git a/src/shared/text/reasoning-tags.ts b/src/shared/text/reasoning-tags.ts new file mode 100644 index 000000000..822138e55 --- /dev/null +++ b/src/shared/text/reasoning-tags.ts @@ -0,0 +1,61 @@ +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; + +function applyTrim(value: string, mode: ReasoningTagTrim): string { + if (mode === "none") return value; + if (mode === "start") return value.trimStart(); + return value.trim(); +} + +export function stripReasoningTagsFromText( + text: string, + options?: { + mode?: ReasoningTagMode; + trim?: ReasoningTagTrim; + }, +): string { + if (!text) return text; + if (!QUICK_TAG_RE.test(text)) return text; + + const mode = options?.mode ?? "strict"; + const trimMode = options?.trim ?? "both"; + + let cleaned = text; + if (FINAL_TAG_RE.test(cleaned)) { + FINAL_TAG_RE.lastIndex = 0; + cleaned = cleaned.replace(FINAL_TAG_RE, ""); + } else { + FINAL_TAG_RE.lastIndex = 0; + } + + THINKING_TAG_RE.lastIndex = 0; + let result = ""; + let lastIndex = 0; + let inThinking = false; + + for (const match of cleaned.matchAll(THINKING_TAG_RE)) { + const idx = match.index ?? 0; + const isClose = match[1] === "/"; + + if (!inThinking) { + result += cleaned.slice(lastIndex, idx); + if (!isClose) { + inThinking = true; + } + } else if (isClose) { + inThinking = false; + } + + lastIndex = idx + match[0].length; + } + + if (!inThinking || mode === "preserve") { + result += cleaned.slice(lastIndex); + } + + return applyTrim(result, trimMode); +} diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index e8f4e4991..461195a7a 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -1,3 +1,5 @@ +import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; + export function formatMs(ms?: number | null): string { if (!ms && ms !== 0) return "n/a"; return new Date(ms).toLocaleString(); @@ -67,38 +69,6 @@ export function parseList(input: string): string[] { .filter((v) => v.length > 0); } -const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|final)\s*>/gi; -const THINKING_OPEN_RE = /<\s*(?:think(?:ing)?|final)\s*>/i; -const THINKING_CLOSE_RE = /<\s*\/\s*(?:think(?:ing)?|final)\s*>/i; - export function stripThinkingTags(value: string): string { - if (!value) return value; - const hasOpen = THINKING_OPEN_RE.test(value); - const hasClose = THINKING_CLOSE_RE.test(value); - if (!hasOpen && !hasClose) return value; - // If we don't have a balanced pair, avoid dropping trailing content. - if (hasOpen !== hasClose) { - if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart(); - return value.replace(THINKING_OPEN_RE, "").trimStart(); - } - - if (!THINKING_TAG_RE.test(value)) return value; - THINKING_TAG_RE.lastIndex = 0; - - let result = ""; - let lastIndex = 0; - let inThinking = false; - for (const match of value.matchAll(THINKING_TAG_RE)) { - const idx = match.index ?? 0; - if (!inThinking) { - result += value.slice(lastIndex, idx); - } - const tag = match[0].toLowerCase(); - inThinking = !tag.includes("/"); - lastIndex = idx + match[0].length; - } - if (!inThinking) { - result += value.slice(lastIndex); - } - return result.trimStart(); + return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" }); }