fix(export): prevent broken emoji in HTML tool call previews (#104054)

This commit is contained in:
xingzhou
2026-07-19 22:00:31 +08:00
committed by GitHub
parent c42ceb8770
commit 7ea4e6701e
2 changed files with 117 additions and 20 deletions

View File

@@ -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) + "...";
}
/**

View File

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