fix(webchat): strip reply/audio directive tags before rendering #18079

The webchat UI rendered [[reply_to_current]], [[reply_to:<id>]], 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
This commit is contained in:
HAL
2026-02-16 07:54:08 -06:00
committed by Peter Steinberger
parent 9c3eed5970
commit e24e465c00
2 changed files with 67 additions and 3 deletions

View File

@@ -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:<id>]]", () => {
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 = {

View File

@@ -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:<id>]]`,
* `[[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<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>();
@@ -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;