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}...`,
+ ]);
+ });
+});