Skip to content

Commit 4d9cd7d

Browse files
Bartok9ly-wang19claudevincentkoc
authored
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) <[email protected]> * 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 <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent 12ea61a commit 4d9cd7d

2 files changed

Lines changed: 24 additions & 1 deletion

File tree

src/agents/tool-display-exec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
77
import { redactToolPayloadText } from "../logging/redact.js";
8+
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
89
import {
910
binaryName,
1011
firstPositional,
@@ -442,7 +443,7 @@ function compactRawCommand(raw: string, maxLength = 120): string {
442443
return oneLine;
443444
}
444445
const half = Math.floor((maxLength - 1) / 2);
445-
return `${oneLine.slice(0, half)}${oneLine.slice(-(maxLength - 1 - half))}`;
446+
return `${sliceUtf16Safe(oneLine, 0, half)}${sliceUtf16Safe(oneLine, -(maxLength - 1 - half))}`;
446447
}
447448

448449
export type ToolDetailMode = "explain" | "raw";

src/agents/tool-display.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,28 @@ describe("compactRawCommand middle truncation", () => {
562562
expect(result).not.toContain("AKIDABCDEFGHIJKLMNOP1234567890");
563563
expect(result).toContain("AKIDAB…7890");
564564
});
565+
566+
it("does not split a surrogate pair when the head boundary lands on an emoji", () => {
567+
// The one-line form is 140 UTF-16 units. With the default maxLength=120 the head
568+
// slice ends at index 59, but the 😀 emoji (U+1F600, a surrogate pair) occupies
569+
// indices 58-59 — so a raw .slice(0, 59) would keep the high surrogate and drop
570+
// its low half, leaving a lone surrogate that renders as the replacement char.
571+
const emoji = String.fromCodePoint(0x1f600);
572+
// Unknown binary so resolveExecDetail returns the compact raw form directly.
573+
const longCommand = `/opt/custom/bin/run ${"a".repeat(38)}${emoji}${"b".repeat(80)}`;
574+
const result = resolveExecDetail({ command: longCommand });
575+
576+
expect(result).toBeDefined();
577+
// The whole emoji is dropped at the boundary rather than half of it.
578+
expect(result).not.toContain(emoji);
579+
// No dangling/lone surrogate code units remain in the rendered detail.
580+
expect(result).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
581+
expect(result).not.toMatch(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
582+
// Start and end of the command are still preserved around the ellipsis.
583+
expect(result).toContain("/opt/custom/bin/run");
584+
expect(result).toContain("…");
585+
expect(result).toMatch(/b{4}$/);
586+
});
565587
});
566588

567589
describe("coerceDisplayValue middle truncation", () => {

0 commit comments

Comments
 (0)