Skip to content

fix(agents): truncate tool-display detail on code-point boundaries#96420

Closed
ly-wang19 wants to merge 1 commit into
openclaw:mainfrom
ly-wang19:fix/tool-display-common-surrogate-safe
Closed

fix(agents): truncate tool-display detail on code-point boundaries#96420
ly-wang19 wants to merge 1 commit into
openclaw:mainfrom
ly-wang19:fix/tool-display-common-surrogate-safe

Conversation

@ly-wang19

Copy link
Copy Markdown
Contributor

What Problem This Solves

coerceDisplayValue in src/agents/tool-display-common.ts builds the compact one-line "detail" shown for tool calls (chat progress lines, CLI activity, UI tool-update streams). When a detail value's first line is longer than maxStringChars (default 160, used whenever resolveToolDisplay passes no detailCoerce), it elides the middle:

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

String.prototype.slice cuts on raw UTF-16 code-unit boundaries. When an emoji (a surrogate pair) straddles the cut, the head keeps the emoji's high surrogate alone and the tail can begin on a lone low surrogate — both render as the replacement character (the box/question-mark glyph).

This is the only remaining raw-slice truncation on this path; sibling code (e.g. embedded-agent-subscribe.tools.ts, chunkByNewline, the LINE/Slack/Matrix adapters) already truncates with the repo's UTF-16-safe helpers.

Before / After repro

Route a detail-key string value through the public entry point with the default maxStringChars = 160:

// detail value: 78×'a' + 😀 (U+1F600, surrogate pair at indices 78-79) + 120×'b'  (length 200)
const detailValue = "a".repeat(78) + "\u{1F600}" + "b".repeat(120);
resolveToolVerbAndDetailForArgs({
  toolKey: "custom_tool",
  args: { note: detailValue },
  fallbackDetailKeys: ["note"],
  detailMode: "first",
});

half = Math.floor(159/2) = 79.

  • Before: firstLine.slice(0, 79) keeps the high surrogate \uD83D at index 78 and drops its low half at index 79 → head ends in a lone high surrogate (renders as the replacement char). The tail slice(-80) can likewise begin on a lone low surrogate \uDE00.
  • After: truncation respects code-point boundaries — the whole emoji is dropped → head is exactly "a".repeat(78), no trailing lone surrogate, and the tail does not begin mid-pair.

Fix

Swap the two raw slices for the existing sliceUtf16Safe helper from src/utils.ts (same one backing truncateUtf16Safe):

return `${sliceUtf16Safe(firstLine, 0, half)}${sliceUtf16Safe(firstLine, -(maxStringChars - 1 - half))}`;

sliceUtf16Safe nudges the cut off a dangling surrogate at either edge. For inputs with no surrogate at the boundary (the overwhelming common case, including all-ASCII), it returns byte-identical output, so this is behavior-preserving.

Evidence

Node red-green proof (/tmp/verify-tool-display-common-surrogate-safe.mjs) replicating the truncation branch with sliceUtf16Safe copied verbatim from src/utils.ts, plus a hasLoneSurrogate scanner:

half = 79
BUGGY head ends with high-surrogate?  true
BUGGY hasLoneSurrogate = true
FIXED hasLoneSurrogate = false
FIXED head = 78 'a's (emoji dropped) OK
FIXED tail does not begin mid-pair OK
non-surrogate long input: old === new OK
short (untruncated) input: unchanged OK
ascii-at-boundary input: old === new OK

ALL ASSERTIONS PASSED
  • RED: the old slice logic produces a lone surrogate (bug reproduced).
  • GREEN: the fixed logic produces no lone surrogate and drops the whole emoji.
  • Behavior-preserving: a 300×x long value and a long all-ASCII value both produce byte-identical output before vs after; short (untruncated) values are unchanged.

A unit test exercising the same case through the exported public API resolveToolVerbAndDetailForArgs is added in src/agents/tool-display-common.test.ts (asserts no lone surrogate, head === "a".repeat(78), tail not starting mid-pair, plus the ASCII-truncation and short-value behavior-preservation cases). No new export was needed — the existing public function reaches coerceDisplayValue via resolveDetailFromKeys.

🤖 Generated with Claude Code

coerceDisplayValue truncated a long first-line detail value with raw
UTF-16 slice() at half = floor((maxStringChars-1)/2). When an emoji
(surrogate pair) straddles the cut boundary, the head kept a lone high
surrogate and the tail could begin on a lone low surrogate, rendering as
the replacement character. Use the existing sliceUtf16Safe helper so the
whole code point is dropped at the boundary, matching the UTF-16-safe
truncation used elsewhere in the repo. 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: S 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: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant