Skip to content

Commit 6940d70

Browse files
ly-wang19claude
authored andcommitted
fix(chunk): keep surrogate pairs whole when hard-splitting an over-long 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]>
1 parent 9636bea commit 6940d70

2 files changed

Lines changed: 17 additions & 1 deletion

File tree

src/auto-reply/chunk.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,19 @@ describe("chunkByNewline", () => {
513513
it.each(["", " \n\n "] as const)("returns empty array for input %j", (text) => {
514514
expect(chunkByNewline(text, 100)).toStrictEqual([]);
515515
});
516+
517+
it("does not split surrogate pairs when hard-splitting an over-long line", () => {
518+
// An emoji-dense line with no break point forces the raw head cut at an odd code-unit offset;
519+
// it must back off to a code-point boundary so no chunk ends in a high (or starts with a low)
520+
// surrogate — the same contract the recursive chunkText path already honors.
521+
const text = "😀".repeat(30);
522+
const chunks = chunkByNewline(text, 11);
523+
524+
expect(chunks.join("")).toBe(text);
525+
expect(chunks.length).toBeGreaterThan(1);
526+
expect(chunks.every((chunk) => !/[\uD800-\uDBFF]$/u.test(chunk))).toBe(true);
527+
expect(chunks.every((chunk) => !/^[\uDC00-\uDFFF]/u.test(chunk))).toBe(true);
528+
});
516529
});
517530

518531
describe("chunkTextWithMode", () => {

src/auto-reply/chunk.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ export function chunkByNewline(
163163
continue;
164164
}
165165

166-
const firstLimit = Math.max(1, maxLineLength - prefix.length);
166+
// Back the head cut off to a code-point boundary so an over-long line never splits a surrogate
167+
// pair; the recursive chunkText below is already surrogate-safe, only this first cut was raw.
168+
const rawLimit = Math.max(1, maxLineLength - prefix.length);
169+
const firstLimit = avoidTrailingHighSurrogateBreak(lineValue, 0, rawLimit);
167170
const first = lineValue.slice(0, firstLimit);
168171
chunks.push(prefix + first);
169172
const remaining = lineValue.slice(firstLimit);

0 commit comments

Comments
 (0)