From e24e465c00c5e884960ea701bcade71c4bf687eb Mon Sep 17 00:00:00 2001 From: HAL Date: Mon, 16 Feb 2026 07:54:08 -0600 Subject: [PATCH] fix(webchat): strip reply/audio directive tags before rendering #18079 The webchat UI rendered [[reply_to_current]], [[reply_to:]], and [[audio_as_voice]] tags as literal text because extractText() passed assistant content through without stripping inline directives. Add stripDirectiveTags() to the UI chat layer and apply it to all three extractText code paths (string content, content array, .text property) for assistant messages only. Regex mirrors src/utils/directive-tags.ts. Fixes #18079 --- ui/src/ui/chat/message-extract.test.ts | 42 ++++++++++++++++++++++++++ ui/src/ui/chat/message-extract.ts | 28 +++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts index 70dd28e001..5df1fc329f 100644 --- a/ui/src/ui/chat/message-extract.test.ts +++ b/ui/src/ui/chat/message-extract.test.ts @@ -25,6 +25,48 @@ describe("extractTextCached", () => { }); }); +describe("extractText strips directive tags from assistant messages", () => { + it("strips [[reply_to_current]]", () => { + const message = { + role: "assistant", + content: "Hello there [[reply_to_current]]", + }; + expect(extractText(message)).toBe("Hello there"); + }); + + it("strips [[reply_to:]]", () => { + const message = { + role: "assistant", + content: [{ type: "text", text: "Done [[reply_to: abc123]]" }], + }; + expect(extractText(message)).toBe("Done"); + }); + + it("strips [[audio_as_voice]]", () => { + const message = { + role: "assistant", + content: "Listen up [[audio_as_voice]]", + }; + expect(extractText(message)).toBe("Listen up"); + }); + + it("does not strip tags from user messages", () => { + const message = { + role: "user", + content: "Hello [[reply_to_current]]", + }; + expect(extractText(message)).toBe("Hello [[reply_to_current]]"); + }); + + it("strips tag from .text property", () => { + const message = { + role: "assistant", + text: "Hi [[reply_to_current]]", + }; + expect(extractText(message)).toBe("Hi"); + }); +}); + describe("extractThinkingCached", () => { it("matches extractThinking output", () => { const message = { diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index d36ead000f..e49da577b6 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -1,6 +1,19 @@ import { stripEnvelope } from "../../../../src/shared/chat-envelope.js"; import { stripThinkingTags } from "../format.ts"; +/** + * Strip inline directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, + * `[[audio_as_voice]]`) that should never be rendered to the user. + * Matches the same patterns as `src/utils/directive-tags.ts`. + */ +function stripDirectiveTags(text: string): string { + return text + .replace(/\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+|audio_as_voice)\s*\]\]/gi, "") + .replace(/[ \t]+/g, " ") + .replace(/[ \t]*\n[ \t]*/g, "\n") + .trim(); +} + const textCache = new WeakMap(); const thinkingCache = new WeakMap(); @@ -9,7 +22,10 @@ export function extractText(message: unknown): string | null { const role = typeof m.role === "string" ? m.role : ""; const content = m.content; if (typeof content === "string") { - const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); + let processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); + if (role === "assistant") { + processed = stripDirectiveTags(processed); + } return processed; } if (Array.isArray(content)) { @@ -24,12 +40,18 @@ export function extractText(message: unknown): string | null { .filter((v): v is string => typeof v === "string"); if (parts.length > 0) { const joined = parts.join("\n"); - const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); + let processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); + if (role === "assistant") { + processed = stripDirectiveTags(processed); + } return processed; } } if (typeof m.text === "string") { - const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); + let processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); + if (role === "assistant") { + processed = stripDirectiveTags(processed); + } return processed; } return null;