Skip to content

Commit 4b72ae3

Browse files
fix(export): prevent broken emoji in HTML tool call previews
1 parent 5f4f249 commit 4b72ae3

2 files changed

Lines changed: 117 additions & 20 deletions

File tree

src/auto-reply/reply/export-html/template.js

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,25 @@
640640
return p;
641641
}
642642

643+
function truncateUtf16Safe(s, maxLen) {
644+
const limit = Math.max(0, Math.floor(maxLen));
645+
if (s.length <= limit) {
646+
return s;
647+
}
648+
649+
let end = limit;
650+
if (end > 0) {
651+
const lastCodeUnit = s.charCodeAt(end - 1);
652+
const nextCodeUnit = s.charCodeAt(end);
653+
const endsWithHighSurrogate = lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff;
654+
const continuesWithLowSurrogate = nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff;
655+
if (endsWithHighSurrogate && continuesWithLowSurrogate) {
656+
end -= 1;
657+
}
658+
}
659+
return s.slice(0, end);
660+
}
661+
643662
function formatToolCall(name, args) {
644663
switch (name) {
645664
case "read": {
@@ -660,11 +679,8 @@
660679
return `[edit: ${shortenPath(String(args.path || args.file_path || ""))}]`;
661680
case "bash": {
662681
const rawCmd = String(args.command || "");
663-
const cmd = rawCmd
664-
.replace(/[\n\t]/g, " ")
665-
.trim()
666-
.slice(0, 50);
667-
return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`;
682+
const cmd = rawCmd.replace(/[\n\t]/g, " ").trim();
683+
return `[bash: ${truncateUtf16Safe(cmd, 50)}${rawCmd.length > 50 ? "..." : ""}]`;
668684
}
669685
case "grep":
670686
return `[grep: /${args.pattern || ""}/ in ${shortenPath(String(args.path || "."))}]`;
@@ -673,8 +689,9 @@
673689
case "ls":
674690
return `[ls: ${shortenPath(String(args.path || "."))}]`;
675691
default: {
676-
const argsStr = JSON.stringify(args).slice(0, 40);
677-
return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`;
692+
const argsJson = JSON.stringify(args);
693+
const argsStr = truncateUtf16Safe(argsJson, 40);
694+
return `[${name}: ${argsStr}${argsJson.length > 40 ? "..." : ""}]`;
678695
}
679696
}
680697
}
@@ -726,19 +743,7 @@
726743
if (s.length <= maxLen) {
727744
return s;
728745
}
729-
let endOffset = maxLen;
730-
const beforeBoundary = s.charCodeAt(endOffset - 1);
731-
const afterBoundary = s.charCodeAt(endOffset);
732-
// Keep the existing UTF-16 unit ceiling, but retreat if it splits a surrogate pair.
733-
if (
734-
beforeBoundary >= 0xd800 &&
735-
beforeBoundary <= 0xdbff &&
736-
afterBoundary >= 0xdc00 &&
737-
afterBoundary <= 0xdfff
738-
) {
739-
endOffset -= 1;
740-
}
741-
return s.slice(0, endOffset) + "...";
746+
return truncateUtf16Safe(s, maxLen) + "...";
742747
}
743748

744749
/**

src/auto-reply/reply/export-html/template.security.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,3 +792,95 @@ describe("export html security hardening", () => {
792792
}
793793
});
794794
});
795+
796+
describe("export html tool call previews", () => {
797+
it("truncates tool previews without splitting emoji", async () => {
798+
const bashPrefix = "a".repeat(49);
799+
const genericPrefix = "b".repeat(29);
800+
const bashExecutionPrefix = "c".repeat(99);
801+
const session: SessionData = {
802+
header: { id: "session-tool-preview-emoji", timestamp: now() },
803+
entries: [
804+
{
805+
id: "1",
806+
parentId: null,
807+
timestamp: now(),
808+
type: "message",
809+
message: { role: "user", content: "run tools" },
810+
},
811+
{
812+
id: "2",
813+
parentId: "1",
814+
timestamp: now(),
815+
type: "message",
816+
message: {
817+
role: "assistant",
818+
content: [
819+
{
820+
type: "toolCall",
821+
id: "call-bash",
822+
name: "bash",
823+
arguments: { command: `${bashPrefix}😀tail` },
824+
},
825+
{
826+
type: "toolCall",
827+
id: "call-custom",
828+
name: "custom",
829+
arguments: { value: `${genericPrefix}😀tail` },
830+
},
831+
],
832+
},
833+
},
834+
{
835+
id: "3",
836+
parentId: "2",
837+
timestamp: now(),
838+
type: "message",
839+
message: {
840+
role: "toolResult",
841+
toolCallId: "call-bash",
842+
content: "bash output",
843+
},
844+
},
845+
{
846+
id: "4",
847+
parentId: "3",
848+
timestamp: now(),
849+
type: "message",
850+
message: {
851+
role: "toolResult",
852+
toolCallId: "call-custom",
853+
content: "custom output",
854+
},
855+
},
856+
{
857+
id: "5",
858+
parentId: "4",
859+
timestamp: now(),
860+
type: "message",
861+
message: {
862+
role: "bashExecution",
863+
command: `${bashExecutionPrefix}😀tail`,
864+
},
865+
},
866+
],
867+
leafId: "5",
868+
systemPrompt: "",
869+
tools: [],
870+
};
871+
872+
const { document } = await renderTemplate(session);
873+
const previews = ["3", "4", "5"].map((id) =>
874+
requireElement(
875+
document.querySelector(`.tree-node[data-id="${id}"] .tree-content`),
876+
`tool preview ${id} missing`,
877+
).textContent,
878+
);
879+
880+
expect(previews).toEqual([
881+
`[bash: ${bashPrefix}...]`,
882+
`[custom: {"value":"${genericPrefix}...]`,
883+
`[bash]: ${bashExecutionPrefix}...`,
884+
]);
885+
});
886+
});

0 commit comments

Comments
 (0)