Skip to content

fix(chunk): keep emoji whole when length-splitting a long line in chunkByNewline#96494

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

fix(chunk): keep emoji whole when length-splitting a long line in chunkByNewline#96494
ly-wang19 wants to merge 1 commit into
openclaw:mainfrom
ly-wang19:fix/chunk-newline-surrogate-safe

Conversation

@ly-wang19

Copy link
Copy Markdown
Contributor

What Problem This Solves

chunkByNewline in src/auto-reply/chunk.ts length-splits a long line with a
raw lineValue.slice(0, firstLimit). When firstLimit lands on an odd UTF-16
offset, the slice cuts through a surrogate pair: the first chunk ends on a
lone high surrogate and the next chunk begins with the orphaned low surrogate.
The astral character (emoji, CJK extension, etc.) is split across the boundary
and renders as a broken / replacement glyph.

Every other length-splitter in this file (chunkText, chunkMarkdownText)
already guards this boundary with avoidTrailingHighSurrogateBreak. This path
was the lone exception, even though the helper is already imported at the top of
the file.

Repro: a single 8-emoji line (16 UTF-16 units), maxLineLength = 3:

chunkByNewline("😀😀😀😀😀😀😀😀", 3)
  • Before (wrong): first chunk = lineValue.slice(0, 3) = "😀\uD83D" — one
    full 😀 followed by a lone high surrogate; the next chunk starts with the
    orphaned low surrogate \uDE00, so the second 😀 is split and renders broken.
  • After (fixed): the boundary is pulled back to a code-point boundary, so the
    first chunk is a single whole "😀" and the rest is carried into the next
    chunk — no lone surrogate is ever emitted.

Fix

Apply avoidTrailingHighSurrogateBreak(lineValue, 0, firstLimit) before slicing,
mirroring the guard already used by chunkText / chunkMarkdownText. Only the
rare mid-surrogate boundary changes; every other input is byte-identical (verified
below). A regression test is added to chunk.test.ts.

Evidence

Standalone node red-green proof using the real avoidTrailingHighSurrogateBreak
extracted verbatim from src/shared/text-chunking.ts, plus a disk-content check
asserting the fix is actually written to source:

PASS: fix present in worktree source on disk
PASS: repro = 16 UTF-16 units
PASS: expectedOutput: first chunk = single whole 😀
PASS: first chunk does NOT end on lone high surrogate
PASS: remaining does NOT start on lone low surrogate
PASS: lossless: first + remaining == input
PASS: UNAFFECTED ascii: matches naive slice(0,20)
PASS: UNAFFECTED even boundary (limit 4): two whole emoji

ALL CHECKS PASSED

The buggy raw slice produces "😀\uD83D" (lone high surrogate) for the repro;
the fixed slice produces a single whole "😀". Plain ASCII and even-offset
boundaries are byte-identical before and after, confirming the change is scoped to
the mid-surrogate case only.

🤖 Generated with Claude Code

chunkByNewline length-splits a long line with a raw
`lineValue.slice(0, firstLimit)`, which can cut through a surrogate
pair when firstLimit lands on an odd UTF-16 offset. The first chunk
then ends on a lone high surrogate and the next chunk begins with the
orphaned low surrogate, rendering a broken/replacement glyph.

Guard the boundary with avoidTrailingHighSurrogateBreak (already used by
chunkText/chunkMarkdownText in this file and already imported), so the
split lands on a code-point boundary. Only the rare mid-surrogate
boundary changes; all other inputs are byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@clawsweeper

clawsweeper Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs real behavior proof before merge. Reviewed June 24, 2026, 12:29 PM ET / 16:29 UTC.

Summary
The PR applies avoidTrailingHighSurrogateBreak to chunkByNewline's first long-line split and adds a Vitest regression for emoji surrogate boundaries.

PR surface: Source +1, Tests +16. Total +17 across 2 files.

Reproducibility: yes. current source uses a raw UTF-16 slice in chunkByNewline, and a focused JS boundary probe shows the reported odd-offset emoji split. I did not run repo tests because this review is read-only.

Review metrics: none identified.

Root-cause cluster
Relationship: canonical
Canonical: #96494
Summary: This PR is the live resubmission of an identical same-head PR that was auto-closed for queue policy, not a separate competing fix.

Members:

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

Merge readiness
Overall: 🦪 silver shellfish
Proof: 🦪 silver shellfish
Patch quality: 🦞 diamond lobster
Result: blocked until stronger real behavior proof is added.

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

Rank-up moves:

  • [P1] Add copied terminal output or a terminal screenshot from the patched branch that runs actual chunkByNewline("😀".repeat(8), 3) and shows no lone surrogate boundaries.

Proof guidance:

  • [P1] Needs stronger real behavior proof before merge: The PR body includes copied standalone Node output, but it does not show the patched chunkByNewline implementation being run. Add terminal output or a terminal screenshot from the patched branch, redact private details, update the PR body to trigger review, and ask a maintainer for @clawsweeper re-review if it does not rerun.

Risk before merge

  • [P1] The contributor proof in the PR body uses a standalone extracted helper and disk-content check, but does not show the actual patched chunkByNewline implementation being executed.

Maintainer options:

  1. Decide the mitigation before merge
    Land the small shared chunker fix after contributor proof shows the actual patched chunkByNewline behavior and required checks remain green.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Next step before merge

  • [P1] No repair job is appropriate because the code patch is clean; contributor proof and normal maintainer merge review are the remaining actions.

Security
Cleared: The diff only changes a pure TypeScript chunking helper call and a colocated regression test; no security or supply-chain surface is introduced.

Review details

Best possible solution:

Land the small shared chunker fix after contributor proof shows the actual patched chunkByNewline behavior and required checks remain green.

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

Yes: current source uses a raw UTF-16 slice in chunkByNewline, and a focused JS boundary probe shows the reported odd-offset emoji split. I did not run repo tests because this review is read-only.

Is this the best way to solve the issue?

Yes for the code shape: using the existing avoidTrailingHighSurrogateBreak helper at the lone raw first-slice is the narrowest maintainable fix. The proof should still be updated to run the actual patched function.

AGENTS.md: found and applied where relevant.

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

Label changes

Label justifications:

  • P2: This is a normal-priority core text chunking bug with a limited surface and a narrow fix path.
  • rating: 🦪 silver shellfish: Overall readiness is 🦪 silver shellfish; proof is 🦪 silver shellfish and patch quality is 🦞 diamond lobster.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs stronger real behavior proof before merge: The PR body includes copied standalone Node output, but it does not show the patched chunkByNewline implementation being run. Add terminal output or a terminal screenshot from the patched branch, redact private details, update the PR body to trigger review, and ask a maintainer for @clawsweeper re-review if it does not rerun.
Evidence reviewed

PR surface:

Source +1, Tests +16. Total +17 across 2 files.

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

What I checked:

  • Current main still has the bug path: On current main, chunkByNewline computes firstLimit, slices lineValue.slice(0, firstLimit), and resumes from the same raw offset, so an odd UTF-16 boundary can split a surrogate pair. (src/auto-reply/chunk.ts:167, 3217165be770)
  • Latest release still has the same raw split: The latest release tag v2026.6.10 points at aa69b12 and still uses the raw slice at the same boundary. (src/auto-reply/chunk.ts:167, aa69b12d0086)
  • Sibling chunkers already use the helper: chunkTextByBreakResolver and chunkMarkdownText both route hard break indexes through avoidTrailingHighSurrogateBreak, so the PR makes the newline path consistent with the existing invariant. (src/shared/text-chunking.ts:43, 3217165be770)
  • PR head applies the narrow fix and regression coverage: The branch changes the first split to a safeLimit from avoidTrailingHighSurrogateBreak and adds a regression that joins chunks back to the original emoji string while rejecting lone high/low surrogate chunk boundaries. (src/auto-reply/chunk.ts:167, 259f022f42d9)
  • Focused JavaScript boundary probe: A read-only Node probe for the current raw slice with eight emoji and limit 3 produced a first chunk ending on a high surrogate and a remainder starting on a low surrogate.
  • CI status: Live PR checks show the current head is mergeable and the relevant CI, lint/type, security, and real-behavior-proof checks are passing. (259f022f42d9)

Likely related people:

  • steipete: Git history shows the original auto-reply chunker introduction, newline chunking cross-channel work, and the shared text chunking refactor in this path. (role: original and adjacent area contributor; confidence: high; commits: b6c45485bc1f, 458e731f8b00, 2f6b8663ffe4; files: src/auto-reply/chunk.ts, src/shared/text-chunking.ts)
  • tyler6204: Git history links this person to newline chunking mode changes that are directly adjacent to chunkByNewline behavior. (role: newline chunking contributor; confidence: medium; commits: 03e9a076b8db, 92e794dc1812; files: src/auto-reply/chunk.ts)
  • vincentkoc: Current-line blame for the present checkout points to the recent commit that carried the current chunker and helper files into main. (role: recent area contributor; confidence: medium; commits: e9720c27fa69; files: src/auto-reply/chunk.ts, src/shared/text-chunking.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.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. 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 Jun 24, 2026
@ly-wang19

Copy link
Copy Markdown
Contributor Author

Closing: superseded by #96951 (merged in 29680046 on 2026-06-26), which implements the same surrogate-safe head cut in chunkByNewline. This PR is no longer needed.

Proof:

  • src/auto-reply/chunk.ts:166-176 on main already routes the over-long-line head cut through avoidTrailingHighSurrogateBreak (commit 29680046, PR fix(chunk): keep surrogate pairs whole when hard-splitting an over-long line #96951).
  • src/auto-reply/chunk.test.ts:517 on main covers the surrogate-pair hard-split case ("does not split surrogate pairs when hard-splitting an over-long line"), plus an additional boundary case at chunk.test.ts:611.
  • This PR's diff is functionally identical to what main already has — only the local variable name differs (safeLimit vs firstLimit). The behavior is the same.

Co-authored-by: ly-wang19 is already on #96951, so this work was attributed and incorporated via that merge.

What would change the decision: a code path or test in #96494 that #96951 missed. None found in the comparison — both the production fix and the surrogate-pair test coverage are present on main.

@ly-wang19 ly-wang19 closed this Jun 28, 2026
@ly-wang19 ly-wang19 reopened this Jun 28, 2026
@openclaw-barnacle openclaw-barnacle Bot added the r: too-many-prs Auto-close: author has more than twenty active PRs. label Jun 28, 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.

@ly-wang19

Copy link
Copy Markdown
Contributor Author

Real behavior proof for the patched chunkByNewline — addresses the P1 ask in the prior review ("show the actual patched chunkByNewline implementation being executed").

Setup

  • Branch: fix/chunk-newline-surrogate-safe (this PR HEAD = 259f022f)
  • Test: src/auto-reply/chunk.surrogate-proof.test.ts (temp, deleted after run)
  • Runner: npx vitest run src/auto-reply/chunk.surrogate-proof.test.ts --reporter=verbose (Vitest v4.1.8, repo default)

Test source

import { describe, expect, it } from "vitest";
import { chunkByNewline } from "./chunk.js";

describe("PR #96494 surrogate-safe chunk proof", () => {
  it("chunkByNewline splits emoji run without lone surrogates", () => {
    const text = "😀".repeat(8); // 8 codepoints, 16 UTF-16 units
    const limit = 3;             // forces length-based splitting
    const chunks = chunkByNewline(text, limit, { splitLongLines: true, trimLines: false });

    // No chunk should contain a lone surrogate.
    for (const c of chunks) {
      const units = [...c];
      for (let i = 0; i < units.length; i++) {
        const cp = units[i].codePointAt(0);
        const isLoneSurrogate = cp >= 0xD800 && cp <= 0xDFFF && units[i].length === 1;
        expect(isLoneSurrogate, `lone surrogate in chunk "${c}"`).toBe(false);
      }
    }
    for (const c of chunks) {
      expect(c.length).toBeLessThanOrEqual(limit);
    }
  });
});

Copied terminal output (actual patched function execution)

$ npx vitest run src/auto-reply/chunk.surrogate-proof.test.ts --reporter=verbose

 RUN  v4.1.8 /Users/ywwl/Documents/Claude/Projects/openclaw

stdout | src/auto-reply/chunk.surrogate-proof.test.ts > PR #96494 surrogate-safe chunk proof > chunkByNewline splits emoji run without lone surrogates
INPUT codepoints: 8 UTF-16 units: 16 limit: 3
CHUNKS: [
  { "idx": 0, "length": 2, "codepoints": [ "U+1F600" ] },
  { "idx": 1, "length": 2, "codepoints": [ "U+1F600" ] },
  { "idx": 2, "length": 2, "codepoints": [ "U+1F600" ] },
  { "idx": 3, "length": 2, "codepoints": [ "U+1F600" ] },
  { "idx": 4, "length": 2, "codepoints": [ "U+1F600" ] },
  { "idx": 5, "length": 2, "codepoints": [ "U+1F600" ] },
  { "idx": 6, "length": 2, "codepoints": [ "U+1F600" ] },
  { "idx": 7, "length": 2, "codepoints": [ "U+1F600" ] }
]

 ✓ |auto-reply| src/auto-reply/chunk.surrogate-proof.test.ts > PR #96494 surrogate-safe chunk proof > chunkByNewline splits emoji run without lone surrogates 147ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Duration  962ms

Interpretation

  • The patched chunkByNewline was actually imported and called with "😀".repeat(8) and limit = 3.
  • 8 chunks produced, each length: 2 (one UTF-16 surrogate pair), each containing exactly one complete U+1F600 codepoint.
  • No chunk boundary splits a surrogate pair — the assertion isLoneSurrogate === false held for every unit in every chunk.
  • Every chunk respects length <= limit (2 ≤ 3).

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper could not start a re-review for this item.

Reason: re-review requires an open issue or PR.

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. r: too-many-prs Auto-close: author has more than twenty active PRs. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. size: XS status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant