Skip to content

fix(agents): truncate exec command detail on code-point boundaries#96424

Closed
ly-wang19 wants to merge 1 commit into
openclaw:mainfrom
ly-wang19:fix/exec-detail-surrogate-safe
Closed

fix(agents): truncate exec command detail on code-point boundaries#96424
ly-wang19 wants to merge 1 commit into
openclaw:mainfrom
ly-wang19:fix/exec-detail-surrogate-safe

Conversation

@ly-wang19

Copy link
Copy Markdown
Contributor

What Problem This Solves

compactRawCommand in src/agents/tool-display-exec.ts builds the short, redacted exec-command label shown in tool-call summaries (chat timeline and transcripts). When the one-line command is longer than maxLength (default 120), it middle-truncates with raw String.prototype.slice:

const half = Math.floor((maxLength - 1) / 2);
return `${oneLine.slice(0, half)}${oneLine.slice(-(maxLength - 1 - half))}`;

String.prototype.slice operates on UTF-16 code units, not code points. When a truncation boundary falls between the two halves of a surrogate pair (e.g. an emoji such as 😀 U+1F600, encoded as the surrogate pair D83D DE00), the head slice keeps the high surrogate \uD83D and drops its low half — leaving a lone surrogate that renders as the replacement character in the displayed detail. The symmetric tail slice can likewise begin on a lone low surrogate.

Before / after repro

Input (normalized one-line form is 140 UTF-16 units; the 😀 high surrogate sits at the head boundary index 58):

git commit -m aaaaaaaa…aaaa😀bbbb…bbbb   (44× 'a', emoji, 80× 'b')
  • Before: head = git commit -m aaa…aaa\uD83D — a dangling high surrogate, shown as …aaa�… in the tool-call summary.
  • After: the whole emoji is dropped at the boundary; head = git commit -m aaa…aaa (no trailing lone surrogate), matching the repo's existing UTF-16-safe behavior.

Fix

Use the existing sliceUtf16Safe helper from src/utils.ts for both ends. It already nudges either edge off a dangling surrogate half (the same helper backing truncateUtf16Safe). The change is behavior-preserving for all non-surrogate input (byte-identical output).

return `${sliceUtf16Safe(oneLine, 0, half)}${sliceUtf16Safe(oneLine, -(maxLength - 1 - half))}`;

A regression test was added to src/agents/tool-display.test.ts (alongside the existing compactRawCommand middle truncation cases), exercising the public resolveExecDetail API with an unknown binary so the compact raw form is returned directly. It asserts the rendered detail contains no lone surrogate and drops the whole emoji.

Evidence

Standalone node red-green proof replicating the exact compactRawCommand logic before and after the fix, plus sliceUtf16Safe copied verbatim from src/utils.ts:

--- RED (buggy, current behavior) ---
ok - buggy output contains a lone surrogate (bug reproduced)
ok - buggy head ends with a lone HIGH surrogate \uD83D

--- GREEN (fixed, expected behavior) ---
ok - fixed output has NO lone surrogate
ok - fixed head is the emoji-dropped prefix (ends in 'aaa', emoji fully removed)
ok - fixed output does not contain the (now-dropped) emoji
ok - ellipsis preserved in the middle
ok - tail of the command preserved

--- BEHAVIOR-PRESERVING on non-surrogate input (must be byte-identical) ---
ok - non-surrogate input unchanged: short (no truncation)
ok - non-surrogate input unchanged: long ASCII truncated
ok - non-surrogate input unchanged: long ASCII path command
ok - non-surrogate input unchanged: whitespace-collapsing

--- tail-side surrogate boundary also safe ---
ok - tail-side fixed output has no lone surrogate

=== verification complete; exitCode: 0 ===

The bug reproduces with the buggy slice (lone \uD83D at the head boundary) and is gone after switching to sliceUtf16Safe, while every non-surrogate input is byte-for-byte unchanged.

🤖 Generated with Claude Code

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]>
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: XS r: too-many-prs Auto-close: author has more than twenty active PRs. labels Jun 24, 2026
@openclaw-barnacle

Copy link
Copy Markdown

Closing this PR because the author has more than 20 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling r: too-many-prs Auto-close: author has more than twenty active PRs. size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant