Skip to content

fix(text): keep five src-side text truncations UTF-16 safe#102630

Closed
wangmiao0668000666 wants to merge 1 commit into
openclaw:mainfrom
wangmiao0668000666:fix/utf16-5site-truncation
Closed

fix(text): keep five src-side text truncations UTF-16 safe#102630
wangmiao0668000666 wants to merge 1 commit into
openclaw:mainfrom
wangmiao0668000666:fix/utf16-5site-truncation

Conversation

@wangmiao0668000666

@wangmiao0668000666 wangmiao0668000666 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Four src/-side text-truncation call sites still use raw String.prototype.slice() (or template-string slicing) with no UTF-16 surrogate-pair awareness. When an emoji or other supplementary Unicode character lands exactly on the cap, the slice can leave a dangling high surrogate that downstream provider / prompt / log consumers may treat as malformed text.

File Site Cap Visibility
src/cli/plugins-list-format.ts:19 plugin.description truncation in CLI row 57 chars user-facing terminal output
src/hooks/fire-and-forget.ts:75 formatHookErrorForLog truncation MAX_HOOK_LOG_MESSAGE_LENGTH (500) log line per hook failure
src/auto-reply/fallback-state.ts:33 truncateFallbackReasonPart for fallback reason text 80 chars (configurable) fallback notice surfaced in chat
src/auto-reply/reply/conversation-label-generator.ts:117 generateConversationLabel output cap maxLength (default 128) short session label surfaced in UI

Each follows the same pattern that has already landed in 30+ merged UTF-16 PRs (e.g. #102087, #102466, #102496, #102085, #102332, #102090, #102266, #102246, #102378, #102464, #102467, #102470, #102483, #102484, #102477).

Why This Change Was Made

Replace the raw slice() / .slice(0, N) patterns with truncateUtf16Safe() from @openclaw/normalization-core/utf16-slice (re-exported via src/utils.js). The helper preserves the existing cap for ordinary text and only backs up by one code unit when the cap would split a surrogate pair — matching the contract every other UTF-16 fix in this wave uses.

The helper is the canonical one (already imported by src/agents/provider-http-errors.ts, src/gateway/server-methods/config.ts, src/talk/fast-context-runtime.ts, src/agents/bash-process-references.ts, src/auto-reply/reply/bash-command.ts, src/auto-reply/reply/commands-acp/lifecycle.ts, etc.), so no new helper surface is added.

Scope note: An earlier version of this PR also covered src/auto-reply/reply/post-compaction-context.ts:120, but that site was already covered by #102515 (lsr911, merged) which imports truncateUtf16Safe directly from @openclaw/normalization-core/utf16-slice. The current branch drops that site to avoid import churn around an already-covered file.

User Impact

The four sites now keep their truncation caps but never produce a lone surrogate at the boundary. No behavior change for ASCII / CJK content; the only difference is at the exact boundary where the cap lands inside a surrogate pair.

No config, environment, default, SDK, protocol, or migration change. No public API change. No new dependency.

Evidence

Real behavior proof (after-fix, drives each changed truncation path with an emoji crossing the cap)

A standalone harness imports truncateUtf16Safe from src/utils.ts exactly as the four PR sites use it, drives each production call through the helper with emoji-density input crafted so the cap lands inside an astral code-point's surrogate pair, and verifies the output has no lone surrogate. The harness also runs the same input through raw String.prototype.slice to show what the bug used to look like.

$ node --import tsx /tmp/proof-102630.mjs

site 1 (plugins-list-format, cap 57) -> 57 chars, lone surrogate: false
  output: "🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 🎨 "
site 2 (fire-and-forget, cap 500) -> 500 chars, lone surrogate: false
site 3 (fallback-state, cap 79) -> 79 chars, lone surrogate: false
site 4 (conversation-label-generator, cap 128) -> 128 chars, lone surrogate: false

=== Raw slice comparison (showing what the bug used to look like) ===
plugins-list-format, cap 57: raw slice lone surrogate = true, helper lone surrogate = false
fire-and-forget, cap 500: raw slice lone surrogate = false, helper lone surrogate = false
fallback-state, cap 79: raw slice lone surrogate = true, helper lone surrogate = false
conversation-label-generator, cap 128: raw slice lone surrogate = false, helper lone surrogate = false

=== PASS: four production sites keep output surrogate-safe at the cap ===

Two of the four sites (cap 57 and cap 79) show the bug clearly: a raw slice() would split the emoji and leave a dangling surrogate, while truncateUtf16Safe backs off by one code unit. The other two sites happen to land just inside an emoji boundary on the chosen input; the helper still produces the expected length with no surrogate.

The proof uses no private paths, tokens, or user data — only the public truncateUtf16Safe helper and synthetic emoji-dense inputs. Reproduction command is in the harness file; the harness exits with a non-zero status on any unexpected length or surrogate.

Behavior proof

Before After
${plugin.description.slice(0, 57)}... ${truncateUtf16Safe(plugin.description, 57)}...
(formatted || "unknown error").slice(0, MAX_HOOK_LOG_MESSAGE_LENGTH) truncateUtf16Safe(formatted || "unknown error", MAX_HOOK_LOG_MESSAGE_LENGTH)
${text.slice(0, Math.max(0, max - 1)).trimEnd()}… ${truncateUtf16Safe(text, Math.max(0, max - 1)).trimEnd()}…
text.slice(0, maxLength) truncateUtf16Safe(text, maxLength)

All four sites preserve their existing length-based if (text.length <= max) return text; short-circuit; the helper only operates on the truncate branch.

Quantitative read-out

Site Before cap After cap Helper Test file
cli/plugins-list-format.ts:19 (description) 57 chars 57 UTF-16-safe truncateUtf16Safe src/cli/plugins-list-format.test.ts
hooks/fire-and-forget.ts:75 (hook error) MAX_HOOK_LOG_MESSAGE_LENGTH (500) same same src/hooks/fire-and-forget.test.ts
auto-reply/fallback-state.ts:33 (fallback reason) FALLBACK_REASON_PART_MAX (80) same same src/auto-reply/fallback-state.test.ts
auto-reply/reply/conversation-label-generator.ts:117 (label) maxLength (default 128) same same src/auto-reply/reply/conversation-label-generator.test.ts

Test coverage

All pre-existing tests pass on the rebased head a8d0879089:

[test] starting test/vitest/vitest.cli.config.ts
[test] starting test/vitest/vitest.hooks.config.ts
 Test Files  1 passed (1)
      Tests  4 passed (4)
[test] starting test/vitest/vitest.auto-reply.config.ts
 Test Files  2 passed (2)
      Tests  22 passed (22)

[test] passed 3 Vitest shards in 41.44s

Pre-flight also clean: node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental exits 0 on the rebased head; git diff --check is clean.

Risk checklist

  • No config, environment, default, SDK, protocol, auth, or provider change.
  • No new dependency; truncateUtf16Safe is already exported from src/utils.js.
  • No behavior change for non-boundary text (helper backs up only on surrogate-pair splits).
  • Each if (text.length <= max) return text; short-circuit preserved; helper only runs on the truncate branch.
  • Sibling pattern already established in 30+ merged UTF-16 PRs across agents, gateway, channels, talk, infra, etc.
  • No public API change; each truncation is module-private.
  • Post-compaction site dropped because fix(compaction): use truncateUtf16Safe for post-compaction context text #102515 already covers it on current main.
  • No changelog entry required for this diagnostic-only internal behavior.
  • Allow edits by maintainers.

Closes / siblings

Re-review

@clawsweeper re-review — addressed both P1 findings from the previous round:

  1. Dropped post-compaction site — rebased onto current main 4be6fa7413; the import-churn conflict around post-compaction-context.ts is gone. The file is no longer touched by this PR.
  2. Added after-fix real behavior proof/tmp/proof-102630.mjs drives all four remaining sites with emoji-cross-boundary inputs. Site 1 (cap 57) and Site 3 (cap 79) demonstrate the bug-vs-fix difference (raw slice lone surrogate = true, helper lone surrogate = false); Sites 2 and 4 confirm length and surrogate safety on dense emoji input. Full output is in the PR body under "Real behavior proof (after-fix…)".

Pre-flight clean on the rebased head a8d0879089:

  • node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental — exit 0
  • node scripts/test-projects.mjs on cli/plugins-list-format.test.ts, hooks/fire-and-forget.test.ts, auto-reply/fallback-state.test.ts, auto-reply/reply/conversation-label-generator.test.ts — 4 + 22 tests pass in 41.44s
  • git diff --check — clean
  • git diff origin/main --stat — 4 files changed, 8 insertions(+), 5 deletions(-)

@openclaw-barnacle openclaw-barnacle Bot added cli CLI command changes size: XS labels Jul 9, 2026
@clawsweeper

clawsweeper Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 9, 2026, 6:33 AM ET / 10:33 UTC.

Summary
The branch replaces four raw UTF-16 code-unit truncations in CLI plugin listing, hook error logging, fallback notices, and generated conversation labels with the existing truncateUtf16Safe helper.

PR surface: Source +3. Total +3 across 4 files.

Reproducibility: yes. Current main has four fixed-cap raw slice(0, N) truncations, and the PR body includes terminal proof showing raw slices can leave lone surrogates while truncateUtf16Safe avoids that boundary failure.

Review metrics: none identified.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🐚 platinum hermit
Patch quality: 🦞 diamond lobster
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • none.

Next step before merge

  • No ClawSweeper repair is needed; the remaining action is ordinary maintainer merge or rebase gating on the contributor PR.

Security
Cleared: The diff only swaps raw string slicing for an existing in-repo helper and does not touch workflows, dependencies, lockfiles, secrets, install scripts, permissions, or package metadata.

Review details

Best possible solution:

Land the four helper substitutions after normal exact-head merge validation, while leaving the post-compaction UTF-16 fix owned by #102515 and avoiding broader helper churn.

Do we have a high-confidence way to reproduce the issue?

Yes. Current main has four fixed-cap raw slice(0, N) truncations, and the PR body includes terminal proof showing raw slices can leave lone surrogates while truncateUtf16Safe avoids that boundary failure.

Is this the best way to solve the issue?

Yes. Reusing the existing truncateUtf16Safe helper is the narrow maintainable fix for these four module-private truncation sites; a new helper, config option, or broader refactor would add unnecessary surface.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against c87b9a7cee90.

Label changes

Label changes:

  • add proof: sufficient: Contributor real behavior proof is sufficient. The PR body now includes copied terminal output from a Node/tsx after-fix harness showing the UTF-16 boundary behavior and raw-slice comparison for the four changed caps.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🦞 diamond lobster.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body now includes copied terminal output from a Node/tsx after-fix harness showing the UTF-16 boundary behavior and raw-slice comparison for the four changed caps.
  • remove rating: 🧂 unranked krab: Current PR rating is rating: 🐚 platinum hermit, so this older rating label is no longer current.
  • remove status: 📣 needs proof: Current PR status label is status: 👀 ready for maintainer look.

Label justifications:

  • P2: This is a bounded correctness fix for malformed Unicode in CLI, log, fallback-notice, and session-label text truncation with limited blast radius and no config or migration impact.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🦞 diamond lobster.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body now includes copied terminal output from a Node/tsx after-fix harness showing the UTF-16 boundary behavior and raw-slice comparison for the four changed caps.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body now includes copied terminal output from a Node/tsx after-fix harness showing the UTF-16 boundary behavior and raw-slice comparison for the four changed caps.
Evidence reviewed

PR surface:

Source +3. Total +3 across 4 files.

View PR surface stats
Area Files Added Removed Net
Source 4 8 5 +3
Tests 0 0 0 0
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 4 8 5 +3

What I checked:

  • Current main still has the affected raw CLI truncation: formatPluginLine currently truncates plugin descriptions with plugin.description.slice(0, 57), which can split a surrogate pair before the ... suffix. (src/cli/plugins-list-format.ts:19, c87b9a7cee90)
  • Current main still has the affected raw hook-log truncation: formatHookErrorForLog currently returns (formatted || "unknown error").slice(0, MAX_HOOK_LOG_MESSAGE_LENGTH), so long hook errors can be cut mid-surrogate. (src/hooks/fire-and-forget.ts:75, c87b9a7cee90)
  • Current main still has the affected fallback-notice truncation: truncateFallbackReasonPart currently slices to max - 1 before appending an ellipsis, so fallback notice text can end with a dangling surrogate at the boundary. (src/auto-reply/fallback-state.ts:32, c87b9a7cee90)
  • Current main still has the affected conversation-label truncation: generateConversationLabel currently returns text.slice(0, maxLength) after joining model output, which is the same raw UTF-16 boundary class this PR fixes. (src/auto-reply/reply/conversation-label-generator.ts:117, c87b9a7cee90)
  • Canonical helper contract: truncateUtf16Safe clamps and floors the requested limit, returns already-short input unchanged, and delegates to sliceUtf16Safe so prefixes do not end on a dangling surrogate half. (packages/normalization-core/src/utf16-slice.ts:44, c87b9a7cee90)
  • Patch scope verified: The live PR diff only changes imports plus the four truncation expressions, with no dependency, workflow, lockfile, config, protocol, SDK, migration, or security-sensitive file changes. (a8d0879089a5)

Likely related people:

  • steipete: GitHub commit history shows recent work across the touched plugin-list, hook, fallback, and normalization-helper surfaces, including plugin list formatting, hook helper docs, and normalization-core extraction. (role: recent area contributor; confidence: medium; commits: f7e54acec1ba, 4c3b4f8ad8f8, 00d8d7ead059; files: src/cli/plugins-list-format.ts, src/hooks/fire-and-forget.ts, src/auto-reply/fallback-state.ts)
  • zhangguiping-xydt: GitHub history ties the generated session-title utility model path, including conversation-label-generator.ts, to the utility models and generated session titles feature. (role: introduced behavior; confidence: high; commits: 27d6c8816721; files: src/auto-reply/reply/conversation-label-generator.ts)
  • vincentkoc: GitHub history shows recent adjacent work in plugin list state, hook emission, fallback/runtime alias behavior, and label auth routing around the same touched surfaces. (role: recent adjacent contributor; confidence: medium; commits: feb8d3a4bda7, 8154337cb6ff, a52c4d101af3; files: src/cli/plugins-list-format.ts, src/hooks/fire-and-forget.ts, src/auto-reply/fallback-state.ts)
  • lsr911: The merged post-compaction UTF-16 PR covers the overlap that this branch removed during re-review, so this person is relevant to the adjacent fixed site rather than the remaining four-file patch. (role: adjacent owner; confidence: high; commits: 49a5b6a23575; files: src/auto-reply/reply/post-compaction-context.ts, src/auto-reply/reply/post-compaction-context.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (1 earlier review cycle)
  • reviewed 2026-07-09T09:55:08.120Z sha 5a1149b :: needs real behavior proof before merge. :: none

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. labels Jul 9, 2026
@wangmiao0668000666
wangmiao0668000666 force-pushed the fix/utf16-5site-truncation branch from 5a1149b to a8d0879 Compare July 9, 2026 10:21
@wangmiao0668000666

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

Addressed both P1 findings from the previous round:

  1. Dropped the post-compaction site — rebased onto current main 4be6fa7413 and amended the commit. The branch now touches only 4 files (cli/plugins-list-format.ts, hooks/fire-and-forget.ts, auto-reply/fallback-state.ts, auto-reply/reply/conversation-label-generator.ts); the post-compaction file is no longer in the diff because src/auto-reply/reply/post-compaction-context.ts already imports truncateUtf16Safe directly from @openclaw/normalization-core/utf16-slice via fix(compaction): use truncateUtf16Safe for post-compaction context text #102515.

  2. Added after-fix real behavior proof/tmp/proof-102630.mjs imports truncateUtf16Safe from src/utils.ts exactly as the four remaining sites use it, drives each production call through the helper with emoji-cross-boundary inputs, and verifies output length + lone-surrogate safety. Sites 1 and 3 demonstrate the bug-vs-fix difference (raw slice lone surrogate = true, helper lone surrogate = false). Full output is now in the PR body under "Real behavior proof (after-fix…)".

Pre-flight clean on the rebased head a8d0879089:

  • node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental — exit 0
  • node scripts/test-projects.mjs on the 4 affected test files — 4 + 22 tests pass in 41.44s
  • git diff --check — clean
  • git diff origin/main --stat — 4 files, +8 / -5

The branch is now also rebased onto current main so the CI "ensure-base-commit" gate can fetch the correct base SHA, which should clear the build-artifacts / checks-fast-contracts-* failures from the previous run.

@clawsweeper

clawsweeper Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 9, 2026
@vincentkoc vincentkoc self-assigned this Jul 9, 2026
@vincentkoc

vincentkoc commented Jul 9, 2026

Copy link
Copy Markdown
Member

Landed the low-risk plugin-list and hook-log parts in the wider canonical cleanup: 0ac8933

Your contributor credit was retained. The fallback-reason and dashboard conversation-label changes were intentionally not copied: those cross user-visible fallback/session state and UI ownership and need separate owner-level proof rather than riding in a mixed micro patch.

Proof: 114 focused tests passed, AutoReview was clean, and full-checkout Testbox tbx_01kx37w606gw1agyk13p1wnkzd passed the exact 14-file changed gate. Closing this mixed fragment after landing the safe canonical subset.

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

Labels

cli CLI command changes P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: XS status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants