From 4d9cd7d227554dea2ffff4bb2965103d90c984dc Mon Sep 17 00:00:00 2001 From: Bartok Date: Fri, 26 Jun 2026 20:24:14 -0400 Subject: [PATCH] fix(agents): truncate exec command detail on code-point boundaries (#96963) * fix(agents): truncate exec command detail on code-point boundaries compactRawCommand in src/agents/tool-display-exec.ts middle-truncated the one-line command with raw String.prototype.slice. When the head or tail boundary fell between the two UTF-16 code units of a surrogate pair (e.g. an emoji like U+1F600), the slice kept a lone surrogate, which renders as the replacement character in the tool-call summary shown in chat/transcripts. Use the existing sliceUtf16Safe helper for both ends so the boundary falls on a code-point boundary, dropping the whole emoji instead of half of it. This is behavior-preserving for non-surrogate input. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: move surrogate-safe slice helpers to browser-safe module ClawSweeper flagged that importing sliceUtf16Safe from src/utils.ts into tool-display-exec.ts pulls node:fs/os/path into the Control UI browser-shared bundle (ui/vite.config.ts treats tool-display-exec.ts as browser-shared). Move sliceUtf16Safe/truncateUtf16Safe into a self-contained, dependency-free module src/shared/text/surrogate-safe-slice.ts. src/utils.ts re-exports them (zero churn for existing Node-side callers), and tool-display-exec.ts now imports directly from the node-free module so no Node built-ins can leak into the browser bundle. * fix(agents): use shared utf16 helper in exec display --------- Co-authored-by: ly-wang19 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Vincent Koc --- src/agents/tool-display-exec.ts | 3 ++- src/agents/tool-display.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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(/(? {