diff --git a/src/agents/tool-display-exec.ts b/src/agents/tool-display-exec.ts index 7d3f67bc71c..fd8bd0dc949 100644 --- a/src/agents/tool-display-exec.ts +++ b/src/agents/tool-display-exec.ts @@ -5,6 +5,7 @@ */ import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { redactToolPayloadText } from "../logging/redact.js"; +import { sliceUtf16Safe } from "../shared/utf16-slice.js"; import { binaryName, firstPositional, @@ -442,7 +443,7 @@ function compactRawCommand(raw: string, maxLength = 120): string { return oneLine; } const half = Math.floor((maxLength - 1) / 2); - return `${oneLine.slice(0, half)}…${oneLine.slice(-(maxLength - 1 - half))}`; + return `${sliceUtf16Safe(oneLine, 0, half)}…${sliceUtf16Safe(oneLine, -(maxLength - 1 - half))}`; } export type ToolDetailMode = "explain" | "raw"; diff --git a/src/agents/tool-display.test.ts b/src/agents/tool-display.test.ts index 1ad63abd75b..9e878ab7428 100644 --- a/src/agents/tool-display.test.ts +++ b/src/agents/tool-display.test.ts @@ -562,6 +562,28 @@ describe("compactRawCommand middle truncation", () => { expect(result).not.toContain("AKIDABCDEFGHIJKLMNOP1234567890"); expect(result).toContain("AKIDAB…7890"); }); + + it("does not split a surrogate pair when the head boundary lands on an emoji", () => { + // The one-line form is 140 UTF-16 units. With the default maxLength=120 the head + // slice ends at index 59, but the 😀 emoji (U+1F600, a surrogate pair) occupies + // indices 58-59 — so a raw .slice(0, 59) would keep the high surrogate and drop + // its low half, leaving a lone surrogate that renders as the replacement char. + const emoji = String.fromCodePoint(0x1f600); + // Unknown binary so resolveExecDetail returns the compact raw form directly. + const longCommand = `/opt/custom/bin/run ${"a".repeat(38)}${emoji}${"b".repeat(80)}`; + const result = resolveExecDetail({ command: longCommand }); + + expect(result).toBeDefined(); + // The whole emoji is dropped at the boundary rather than half of it. + expect(result).not.toContain(emoji); + // No dangling/lone surrogate code units remain in the rendered detail. + expect(result).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/); + expect(result).not.toMatch(/(? {