diff --git a/src/auto-reply/reply/export-html/template.js b/src/auto-reply/reply/export-html/template.js index eaccff6b2ecf..4104230d3220 100644 --- a/src/auto-reply/reply/export-html/template.js +++ b/src/auto-reply/reply/export-html/template.js @@ -640,6 +640,25 @@ return p; } + function truncateUtf16Safe(s, maxLen) { + const limit = Math.max(0, Math.floor(maxLen)); + if (s.length <= limit) { + return s; + } + + let end = limit; + if (end > 0) { + const lastCodeUnit = s.charCodeAt(end - 1); + const nextCodeUnit = s.charCodeAt(end); + const endsWithHighSurrogate = lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff; + const continuesWithLowSurrogate = nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff; + if (endsWithHighSurrogate && continuesWithLowSurrogate) { + end -= 1; + } + } + return s.slice(0, end); + } + function formatToolCall(name, args) { switch (name) { case "read": { @@ -660,11 +679,8 @@ return `[edit: ${shortenPath(String(args.path || args.file_path || ""))}]`; case "bash": { const rawCmd = String(args.command || ""); - const cmd = rawCmd - .replace(/[\n\t]/g, " ") - .trim() - .slice(0, 50); - return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`; + const cmd = rawCmd.replace(/[\n\t]/g, " ").trim(); + return `[bash: ${truncateUtf16Safe(cmd, 50)}${rawCmd.length > 50 ? "..." : ""}]`; } case "grep": return `[grep: /${args.pattern || ""}/ in ${shortenPath(String(args.path || "."))}]`; @@ -673,8 +689,9 @@ case "ls": return `[ls: ${shortenPath(String(args.path || "."))}]`; default: { - const argsStr = JSON.stringify(args).slice(0, 40); - return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`; + const argsJson = JSON.stringify(args); + const argsStr = truncateUtf16Safe(argsJson, 40); + return `[${name}: ${argsStr}${argsJson.length > 40 ? "..." : ""}]`; } } } @@ -726,19 +743,7 @@ if (s.length <= maxLen) { return s; } - let endOffset = maxLen; - const beforeBoundary = s.charCodeAt(endOffset - 1); - const afterBoundary = s.charCodeAt(endOffset); - // Keep the existing UTF-16 unit ceiling, but retreat if it splits a surrogate pair. - if ( - beforeBoundary >= 0xd800 && - beforeBoundary <= 0xdbff && - afterBoundary >= 0xdc00 && - afterBoundary <= 0xdfff - ) { - endOffset -= 1; - } - return s.slice(0, endOffset) + "..."; + return truncateUtf16Safe(s, maxLen) + "..."; } /** diff --git a/src/auto-reply/reply/export-html/template.security.test.ts b/src/auto-reply/reply/export-html/template.security.test.ts index c8577eddd0f8..1a10e8610041 100644 --- a/src/auto-reply/reply/export-html/template.security.test.ts +++ b/src/auto-reply/reply/export-html/template.security.test.ts @@ -792,3 +792,95 @@ describe("export html security hardening", () => { } }); }); + +describe("export html tool call previews", () => { + it("truncates tool previews without splitting emoji", async () => { + const bashPrefix = "a".repeat(49); + const genericPrefix = "b".repeat(29); + const bashExecutionPrefix = "c".repeat(99); + const session: SessionData = { + header: { id: "session-tool-preview-emoji", timestamp: now() }, + entries: [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { role: "user", content: "run tools" }, + }, + { + id: "2", + parentId: "1", + timestamp: now(), + type: "message", + message: { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-bash", + name: "bash", + arguments: { command: `${bashPrefix}😀tail` }, + }, + { + type: "toolCall", + id: "call-custom", + name: "custom", + arguments: { value: `${genericPrefix}😀tail` }, + }, + ], + }, + }, + { + id: "3", + parentId: "2", + timestamp: now(), + type: "message", + message: { + role: "toolResult", + toolCallId: "call-bash", + content: "bash output", + }, + }, + { + id: "4", + parentId: "3", + timestamp: now(), + type: "message", + message: { + role: "toolResult", + toolCallId: "call-custom", + content: "custom output", + }, + }, + { + id: "5", + parentId: "4", + timestamp: now(), + type: "message", + message: { + role: "bashExecution", + command: `${bashExecutionPrefix}😀tail`, + }, + }, + ], + leafId: "5", + systemPrompt: "", + tools: [], + }; + + const { document } = await renderTemplate(session); + const previews = ["3", "4", "5"].map((id) => + requireElement( + document.querySelector(`.tree-node[data-id="${id}"] .tree-content`), + `tool preview ${id} missing`, + ).textContent, + ); + + expect(previews).toEqual([ + `[bash: ${bashPrefix}...]`, + `[custom: {"value":"${genericPrefix}...]`, + `[bash]: ${bashExecutionPrefix}...`, + ]); + }); +});