fix: sanitize assistant session text (#1456) (thanks @zerone0x)

This commit is contained in:
Peter Steinberger
2026-01-23 07:01:29 +00:00
parent 3fbbac07fe
commit 551685351f
5 changed files with 70 additions and 11 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky. - Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. - Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok. - Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Agents: surface concrete API error details instead of generic AI service errors. - Agents: surface concrete API error details instead of generic AI service errors.
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484) - Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
describe("sanitizeTextContent", () => {
it("strips minimax tool call XML and downgraded markers", () => {
const input =
'Hello <invoke name="tool">payload</invoke></minimax:tool_call> ' +
"[Tool Call: foo (ID: 1)] world";
const result = sanitizeTextContent(input).trim();
expect(result).toBe("Hello world");
expect(result).not.toContain("invoke");
expect(result).not.toContain("Tool Call");
});
it("strips thinking tags", () => {
const input = "Before <think>secret</think> after";
const result = sanitizeTextContent(input).trim();
expect(result).toBe("Before after");
});
});
describe("extractAssistantText", () => {
it("sanitizes blocks without injecting newlines", () => {
const message = {
role: "assistant",
content: [
{ type: "text", text: "Hi " },
{ type: "text", text: "<think>secret</think>there" },
],
};
expect(extractAssistantText(message)).toBe("Hi there");
});
});

View File

@@ -111,9 +111,8 @@ export function stripToolMessages(messages: unknown[]): unknown[] {
* This ensures user-facing text doesn't leak internal tool representations. * This ensures user-facing text doesn't leak internal tool representations.
*/ */
export function sanitizeTextContent(text: string): string { export function sanitizeTextContent(text: string): string {
return stripThinkingTagsFromText( if (!text) return text;
stripDowngradedToolCallText(stripMinimaxToolCallXml(text)), return stripThinkingTagsFromText(stripDowngradedToolCallText(stripMinimaxToolCallXml(text)));
).trim();
} }
export function extractAssistantText(message: unknown): string | undefined { export function extractAssistantText(message: unknown): string | undefined {
@@ -128,11 +127,11 @@ export function extractAssistantText(message: unknown): string | undefined {
const text = (block as { text?: unknown }).text; const text = (block as { text?: unknown }).text;
if (typeof text === "string") { if (typeof text === "string") {
const sanitized = sanitizeTextContent(text); const sanitized = sanitizeTextContent(text);
if (sanitized) { if (sanitized.trim()) {
chunks.push(sanitized); chunks.push(sanitized);
} }
} }
} }
const joined = chunks.join("\n").trim(); const joined = chunks.join("").trim();
return joined ? sanitizeUserFacingText(joined) : undefined; return joined ? sanitizeUserFacingText(joined) : undefined;
} }

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { extractMessageText } from "./commands-subagents.js";
describe("extractMessageText", () => {
it("preserves user text that looks like tool call markers", () => {
const message = {
role: "user",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
});
it("sanitizes assistant tool call markers", () => {
const message = {
role: "assistant",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toBe("Here ok");
});
});

View File

@@ -107,12 +107,14 @@ function normalizeMessageText(text: string) {
return text.replace(/\s+/g, " ").trim(); return text.replace(/\s+/g, " ").trim();
} }
function extractMessageText(message: ChatMessage): { role: string; text: string } | null { export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
const role = typeof message.role === "string" ? message.role : ""; const role = typeof message.role === "string" ? message.role : "";
const shouldSanitize = role === "assistant";
const content = message.content; const content = message.content;
if (typeof content === "string") { if (typeof content === "string") {
const sanitized = sanitizeTextContent(content); const normalized = normalizeMessageText(
const normalized = normalizeMessageText(sanitized); shouldSanitize ? sanitizeTextContent(content) : content,
);
return normalized ? { role, text: normalized } : null; return normalized ? { role, text: normalized } : null;
} }
if (!Array.isArray(content)) return null; if (!Array.isArray(content)) return null;
@@ -122,9 +124,9 @@ function extractMessageText(message: ChatMessage): { role: string; text: string
if ((block as { type?: unknown }).type !== "text") continue; if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text; const text = (block as { text?: unknown }).text;
if (typeof text === "string") { if (typeof text === "string") {
const sanitized = sanitizeTextContent(text); const value = shouldSanitize ? sanitizeTextContent(text) : text;
if (sanitized) { if (value.trim()) {
chunks.push(sanitized); chunks.push(value);
} }
} }
} }