Skip to content

fix(chunk): keep surrogate pairs whole when hard-splitting an over-long line#96951

Merged
vincentkoc merged 1 commit into
openclaw:mainfrom
Bartok9:salvage/96671-chunk-surrogate-firstcut
Jun 26, 2026
Merged

fix(chunk): keep surrogate pairs whole when hard-splitting an over-long line#96951
vincentkoc merged 1 commit into
openclaw:mainfrom
Bartok9:salvage/96671-chunk-surrogate-firstcut

Conversation

@Bartok9

@Bartok9 Bartok9 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

chunkByNewline splits a surrogate pair when it hard-cuts an over-long line that has no break point, so an emoji-dense message can produce a chunk ending in a lone high surrogate (and the next starting with a lone low surrogate) — corrupting the emoji at every chunk boundary.

Salvages #96671 by @ly-wang19 onto current main — clean single-commit rebase, original authorship preserved. The original was auto-closed by openclaw-barnacle for the author's >20-active-PR cap, not on merits; the bug is still live on main.

Root Cause

Symptom — A long unbroken line of emoji (e.g. "😀".repeat(30)) chunked at a small limit yields chunks that end in \uD800–\uDBFF (lone high surrogate) / start with \uDC00–\uDFFF (lone low surrogate). The emoji at the cut is mojibake.

Root cause — In src/auto-reply/chunk.ts, chunkByNewline() computes the head cut as firstLimit = Math.max(1, maxLineLength - prefix.length) and slices the line at that raw UTF-16 code-unit offset. If that offset lands between the two code units of a surrogate pair, the pair is severed. The recursive chunkText path below already routes through avoidTrailingHighSurrogateBreak; only this first cut was raw.

Fix + why this level — Run the existing, already-imported avoidTrailingHighSurrogateBreak(lineValue, 0, rawLimit) on the first cut, mirroring the contract the recursive path already honors. Fixing at the cut site is correct: it's the only place that produces a non-code-point-aligned boundary, and reusing the existing helper keeps both cut paths consistent.

Scope / risk — Touches only the first-cut offset in chunkByNewline. Lines with a natural break point and BMP-only content are unaffected (back-off is a no-op when the boundary is already aligned). chunks.join("") still reconstructs the input exactly.

Evidence

  • Regression test (new): src/auto-reply/chunk.test.ts → "does not split surrogate pairs when hard-splitting an over-long line" — fails without the fix.
  • Captured run on this branch:

WITH fix:

 Test Files  1 passed (1)
      Tests  66 passed (66)

WITHOUT fix (test against current main source for chunk.ts):

      Tests  1 failed | 65 passed (66)

Changes from original

  • Rebased onto current main (clean single commit, no conflicts).
  • No code changes from @ly-wang19's original.

Note: #96415 addressed the same firstLimit cut; this salvage (#96671) is the cleaner of the two and supersedes it.

Credit: Salvage of #96671 by @ly-wang19 — rebased onto current main. Original closed by the >20-active-PR queue cap, not on merits.

@openclaw-barnacle openclaw-barnacle Bot added size: XS triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. labels Jun 26, 2026
@clawsweeper

clawsweeper Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 26, 2026, 5:01 PM ET / 21:01 UTC.

Summary
The PR changes chunkByNewline so the first hard split backs off from a trailing high surrogate and adds a regression test for emoji-dense over-long lines.

PR surface: Source +3, Tests +13. Total +16 across 2 files.

Reproducibility: yes. Source inspection shows current main still slices the first chunkByNewline hard split at a raw UTF-16 boundary, and the PR body gives a concrete emoji-dense input plus fail-before evidence for the new regression test.

Review metrics: none identified.

Root-cause cluster
Relationship: canonical
Canonical: #96951
Summary: This PR is the strongest current candidate for the surrogate-splitting fix because it salvages the closed original and has stronger proof than the other open duplicate.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

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

  • [P2] No repair lane is needed because the PR already contains the narrow code and test change; maintainers only need to choose and land the canonical branch.

Security
Cleared: The diff only changes deterministic text chunking code and a colocated regression test; it does not add dependencies, scripts, permissions, or sensitive-data handling.

Review details

Best possible solution:

Land one proof-backed canonical fix that makes every chunkByNewline hard split preserve surrogate pairs, then close the duplicate open branch as superseded.

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

Yes. Source inspection shows current main still slices the first chunkByNewline hard split at a raw UTF-16 boundary, and the PR body gives a concrete emoji-dense input plus fail-before evidence for the new regression test.

Is this the best way to solve the issue?

Yes. Reusing the existing avoidTrailingHighSurrogateBreak helper at the only raw first-cut site is the narrowest maintainable fix and keeps the recursive path and first split under the same invariant.

AGENTS.md: found and applied where relevant.

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

Label changes

Label justifications:

  • P2: This is a focused user-visible message chunking correctness bug with limited blast radius and a small tested fix.
  • 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 (live_output): The PR body includes captured with-fix and fail-before output, and the linked original PR contains exported chunkByNewline before/after Node output for the same patch; no contributor action is needed for proof.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes captured with-fix and fail-before output, and the linked original PR contains exported chunkByNewline before/after Node output for the same patch; no contributor action is needed for proof.
Evidence reviewed

PR surface:

Source +3, Tests +13. Total +16 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 4 1 +3
Tests 1 13 0 +13
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 17 1 +16

What I checked:

  • Current main still uses a raw first cut: On current main, chunkByNewline computes firstLimit = Math.max(1, maxLineLength - prefix.length) and slices lineValue directly for the first chunk, so the reported mid-surrogate boundary remains present outside the PR. (src/auto-reply/chunk.ts:166, 9636bea901d0)
  • Latest release is still affected: The latest release tag v2026.6.10 has the same raw firstLimit slice in chunkByNewline, so this fix is not already shipped. (src/auto-reply/chunk.ts:166, aa69b12d0086)
  • Existing helper contract already covers this boundary type: avoidTrailingHighSurrogateBreak backs off a split point when it lands between a high and low surrogate, and chunkTextByBreakResolver already applies that helper before slicing chunks. (src/shared/text-chunking.ts:7, 9636bea901d0)
  • Sibling tests define surrogate integrity as expected behavior: Existing markdown chunking tests already assert chunks must not end in a high surrogate or start with a low surrogate; this PR extends the same invariant to chunkByNewline's first hard split. (src/auto-reply/chunk.test.ts:598, 9636bea901d0)
  • Runtime exposure is real: createRuntimeChannel exposes chunkByNewline through runtime.channel.text, so the bug affects outbound channel/plugin helper behavior rather than only internal tests. (src/plugins/runtime/runtime-channel.ts:91, 9636bea901d0)
  • PR diff is narrow: The head commit changes only the first hard split to call avoidTrailingHighSurrogateBreak(lineValue, 0, rawLimit) and adds assertions that chunks join back to the input and never expose unpaired surrogate halves. (src/auto-reply/chunk.ts:166, 6940d706d6e1)

Likely related people:

  • steipete: Git history shows repeated work on the auto-reply chunker, cross-channel newline chunking, and the shared outbound text chunking refactor that now owns the surrogate-safe helper path. (role: original and adjacent area contributor; confidence: high; commits: 458e731f8b00, 2f6b8663ffe4, b6c45485bc1f; files: src/auto-reply/chunk.ts, src/auto-reply/chunk.test.ts, src/shared/text-chunking.ts)
  • tyler6204: Merged history for https://github.com/openclaw/openclaw/pull/1645 added chunking mode behavior and touched the same chunkByNewline module and tests. (role: newline chunking contributor; confidence: medium; commits: 92e794dc1812, 03e9a076b8db, c3f5b4c41667; files: src/auto-reply/chunk.ts, src/auto-reply/chunk.test.ts)
  • ly-wang19: The same focused repair originated in the related closed PR that this branch explicitly salvages onto current main. (role: original fix contributor; confidence: high; commits: 570f928fb742, 6940d706d6e1; files: src/auto-reply/chunk.ts, src/auto-reply/chunk.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.

@openclaw-barnacle openclaw-barnacle Bot removed the triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. label Jun 26, 2026
@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. P2 Normal backlog priority with limited blast radius. labels Jun 26, 2026
@vincentkoc vincentkoc self-assigned this Jun 26, 2026
…ng line

chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.

Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@vincentkoc
vincentkoc force-pushed the salvage/96671-chunk-surrogate-firstcut branch from c15254a to 6940d70 Compare June 26, 2026 20:57
@vincentkoc
vincentkoc merged commit 2968004 into openclaw:main Jun 26, 2026
95 checks passed
@vincentkoc

Copy link
Copy Markdown
Member

Merged via squash.

wangmiao0668000666 pushed a commit to wangmiao0668000666/openclaw that referenced this pull request Jun 27, 2026
…ng line (openclaw#96951)

chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.

Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.

Co-authored-by: ly-wang19 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jun 27, 2026
…ng line (openclaw#96951)

chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.

Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.

Co-authored-by: ly-wang19 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
xydigit-zt pushed a commit to xydigit-zt/xydigit-zt-openclaw that referenced this pull request Jun 28, 2026
…ng line (openclaw#96951)

chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.

Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.

Co-authored-by: ly-wang19 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
QiuYuang pushed a commit to QiuYuang/openclaw that referenced this pull request Jul 1, 2026
…ng line (openclaw#96951)

chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.

Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.

Co-authored-by: ly-wang19 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

3 participants