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;