mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
fix (tui): sanitize binary-heavy history text before render
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { formatToolDetail, resolveToolDisplay } from "../../agents/tool-display.js";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
import { sanitizeRenderableText } from "../tui-formatters.js";
|
||||
|
||||
type ToolResultContent = {
|
||||
type?: string;
|
||||
@@ -21,13 +22,13 @@ function formatArgs(toolName: string, args: unknown): string {
|
||||
const display = resolveToolDisplay({ name: toolName, args });
|
||||
const detail = formatToolDetail(display);
|
||||
if (detail) {
|
||||
return detail;
|
||||
return sanitizeRenderableText(detail);
|
||||
}
|
||||
if (!args || typeof args !== "object") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(args);
|
||||
return sanitizeRenderableText(JSON.stringify(args));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -40,7 +41,7 @@ function extractText(result?: ToolResult): string {
|
||||
const lines: string[] = [];
|
||||
for (const entry of result.content) {
|
||||
if (entry.type === "text" && entry.text) {
|
||||
lines.push(entry.text);
|
||||
lines.push(sanitizeRenderableText(entry.text));
|
||||
} else if (entry.type === "image") {
|
||||
const mime = entry.mimeType ?? "image";
|
||||
const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : "";
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
extractTextFromMessage,
|
||||
extractThinkingFromMessage,
|
||||
isCommandMessage,
|
||||
sanitizeRenderableText,
|
||||
} from "./tui-formatters.js";
|
||||
|
||||
describe("extractTextFromMessage", () => {
|
||||
@@ -58,6 +59,24 @@ describe("extractTextFromMessage", () => {
|
||||
|
||||
expect(text).toBe("[thinking]\nponder\n\nhello");
|
||||
});
|
||||
|
||||
it("sanitizes ANSI and control chars from string content", () => {
|
||||
const text = extractTextFromMessage({
|
||||
role: "assistant",
|
||||
content: "Hello\x1b[31m red\x1b[0m\x00world",
|
||||
});
|
||||
|
||||
expect(text).toBe("Hello redworld");
|
||||
});
|
||||
|
||||
it("redacts heavily corrupted binary-like lines", () => {
|
||||
const text = extractTextFromMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>" }],
|
||||
});
|
||||
|
||||
expect(text).toBe("[binary data omitted]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinkingFromMessage", () => {
|
||||
@@ -106,3 +125,13 @@ describe("isCommandMessage", () => {
|
||||
expect(isCommandMessage({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRenderableText", () => {
|
||||
it("breaks very long unbroken tokens to avoid overflow", () => {
|
||||
const input = "a".repeat(140);
|
||||
const sanitized = sanitizeRenderableText(input);
|
||||
const longestSegment = Math.max(...sanitized.split(/\s+/).map((segment) => segment.length));
|
||||
|
||||
expect(longestSegment).toBeLessThanOrEqual(64);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js";
|
||||
import { stripAnsi } from "../terminal/ansi.js";
|
||||
import { formatTokenCount } from "../utils/usage-format.js";
|
||||
|
||||
const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
||||
const REPLACEMENT_CHAR_RE = /\uFFFD/g;
|
||||
const LONG_TOKEN_RE = /\S{97,}/g;
|
||||
const MAX_TOKEN_CHARS = 64;
|
||||
const BINARY_LINE_REPLACEMENT_THRESHOLD = 12;
|
||||
|
||||
function chunkToken(token: string, maxChars: number): string[] {
|
||||
if (token.length <= maxChars) {
|
||||
return [token];
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < token.length; i += maxChars) {
|
||||
chunks.push(token.slice(i, i + maxChars));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function redactBinaryLikeLine(line: string): string {
|
||||
const replacementCount = (line.match(REPLACEMENT_CHAR_RE) || []).length;
|
||||
if (
|
||||
replacementCount >= BINARY_LINE_REPLACEMENT_THRESHOLD &&
|
||||
replacementCount * 2 >= line.length
|
||||
) {
|
||||
return "[binary data omitted]";
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
export function sanitizeRenderableText(text: string): string {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
const withoutAnsi = stripAnsi(text);
|
||||
const withoutControlChars = withoutAnsi.replace(CONTROL_CHARS_RE, "");
|
||||
const redacted = withoutControlChars
|
||||
.split("\n")
|
||||
.map((line) => redactBinaryLikeLine(line))
|
||||
.join("\n");
|
||||
return redacted.replace(LONG_TOKEN_RE, (token) => chunkToken(token, MAX_TOKEN_CHARS).join(" "));
|
||||
}
|
||||
|
||||
export function resolveFinalAssistantText(params: {
|
||||
finalText?: string | null;
|
||||
streamedText?: string | null;
|
||||
@@ -59,7 +101,7 @@ export function extractThinkingFromMessage(message: unknown): string {
|
||||
}
|
||||
const rec = block as Record<string, unknown>;
|
||||
if (rec.type === "thinking" && typeof rec.thinking === "string") {
|
||||
parts.push(rec.thinking);
|
||||
parts.push(sanitizeRenderableText(rec.thinking));
|
||||
}
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
@@ -77,7 +119,7 @@ export function extractContentFromMessage(message: unknown): string {
|
||||
const content = record.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
return sanitizeRenderableText(content).trim();
|
||||
}
|
||||
|
||||
// Check for error BEFORE returning empty for non-array content
|
||||
@@ -97,7 +139,7 @@ export function extractContentFromMessage(message: unknown): string {
|
||||
}
|
||||
const rec = block as Record<string, unknown>;
|
||||
if (rec.type === "text" && typeof rec.text === "string") {
|
||||
parts.push(rec.text);
|
||||
parts.push(sanitizeRenderableText(rec.text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +157,7 @@ export function extractContentFromMessage(message: unknown): string {
|
||||
|
||||
function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean }): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
return sanitizeRenderableText(content).trim();
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
@@ -130,14 +172,14 @@ function extractTextBlocks(content: unknown, opts?: { includeThinking?: boolean
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "text" && typeof record.text === "string") {
|
||||
textParts.push(record.text);
|
||||
textParts.push(sanitizeRenderableText(record.text));
|
||||
}
|
||||
if (
|
||||
opts?.includeThinking &&
|
||||
record.type === "thinking" &&
|
||||
typeof record.thinking === "string"
|
||||
) {
|
||||
thinkingParts.push(record.thinking);
|
||||
thinkingParts.push(sanitizeRenderableText(record.thinking));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user