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) <noreply@anthropic.com>

* 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 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Bartok
2026-06-26 20:24:14 -04:00
committed by GitHub
parent 12ea61a08d
commit 4d9cd7d227
2 changed files with 24 additions and 1 deletions

View File

@@ -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";

View File

@@ -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(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
// Start and end of the command are still preserved around the ellipsis.
expect(result).toContain("/opt/custom/bin/run");
expect(result).toContain("…");
expect(result).toMatch(/b{4}$/);
});
});
describe("coerceDisplayValue middle truncation", () => {