From b588d5b0b7175752ac55ced0b146940ff35d5bfd Mon Sep 17 00:00:00 2001 From: Hannnnibal Date: Wed, 23 Sep 2026 12:04:27 +0800 Subject: [PATCH] fix(truncation): bound text on code points instead of UTF-16 units normalizeSnippet, sanitizeLine, normalizeSubagentTitle and boundedActivityText measured with String#length and cut with slice(), so a bound landing inside a surrogate pair emitted a lone surrogate into session lists, background-terminal match records, subagent snapshots and the Web capability snapshot. Count and cut on code points instead, which is the pattern already used by post-edit, ask-user and by-the-way. Each site gets a regression test asserting the exact bounded string at an emoji boundary. --- extensions/background-terminals/src/watch.ts | 11 ++++++++--- extensions/sessions/sessions.ts | 7 +++++-- extensions/shared/web-observer-registry.ts | 7 +++++-- extensions/subagents/navigation.ts | 4 +++- .../background-terminals/watch.test.ts | 13 +++++++++++++ tests/extensions/sessions/sessions.test.ts | 15 +++++++++++++++ tests/extensions/subagents/navigation.test.ts | 10 ++++++++++ tests/web/observer-registry.test.ts | 17 +++++++++++++++++ 8 files changed, 76 insertions(+), 8 deletions(-) diff --git a/extensions/background-terminals/src/watch.ts b/extensions/background-terminals/src/watch.ts index 082483d0..ea31ae1f 100644 --- a/extensions/background-terminals/src/watch.ts +++ b/extensions/background-terminals/src/watch.ts @@ -144,7 +144,9 @@ export function createChunkMatcher(pattern: RegExp): ChunkMatcher { }; } -/** Cap on the reported match line, so a newline-free stream cannot smear. */ +/** Cap in code points on the reported match line, so a newline-free stream + * cannot smear. Counted per code point so a bound can never split a surrogate + * pair into a lone surrogate. */ export const WATCH_LINE_MAX_CHARS = 500; /** @@ -154,8 +156,11 @@ export const WATCH_LINE_MAX_CHARS = 500; */ function sanitizeLine(raw: string) { const stripped = sanitizeTerminalText(raw).trim(); - return stripped.length > WATCH_LINE_MAX_CHARS - ? `${stripped.slice(0, WATCH_LINE_MAX_CHARS)}\u2026` + // Count and cut on code points so a surrogate pair is never split into a lone + // surrogate in the transcript. + const characters = [...stripped]; + return characters.length > WATCH_LINE_MAX_CHARS + ? `${characters.slice(0, WATCH_LINE_MAX_CHARS).join("")}\u2026` : stripped; } diff --git a/extensions/sessions/sessions.ts b/extensions/sessions/sessions.ts index a6f29589..36e7b5dd 100644 --- a/extensions/sessions/sessions.ts +++ b/extensions/sessions/sessions.ts @@ -121,9 +121,12 @@ const normalizeSnippet = (text: string, maxLength: number): string => { const cleaned = cleanDisplayLine(text); const fallback = cleaned.length > 0 ? cleaned : "No messages"; if (maxLength < 1) return ""; - if (fallback.length <= maxLength) return fallback; + // Bound on code points so a truncation boundary cannot split a surrogate pair + // into a lone surrogate in the session list. + const characters = [...fallback]; + if (characters.length <= maxLength) return fallback; if (maxLength === 1) return "…"; - return `${fallback.slice(0, maxLength - 1)}…`; + return `${characters.slice(0, maxLength - 1).join("")}…`; }; export function buildSessionDescription( diff --git a/extensions/shared/web-observer-registry.ts b/extensions/shared/web-observer-registry.ts index 1d5a637b..86f08109 100644 --- a/extensions/shared/web-observer-registry.ts +++ b/extensions/shared/web-observer-registry.ts @@ -166,11 +166,14 @@ interface BoundedActivityText { } function boundedActivityText(value: string): BoundedActivityText { - if (value.length <= WEB_MAX_ACTIVITY_TEXT) { + // Cut on code points: a UTF-16 unit cut can split a surrogate pair and emit a + // lone surrogate, which the JSON capability snapshot cannot represent. + const characters = [...value]; + if (characters.length <= WEB_MAX_ACTIVITY_TEXT) { return { value, truncated: false }; } return { - value: `${value.slice(0, WEB_MAX_ACTIVITY_TEXT - 1)}…`, + value: `${characters.slice(0, WEB_MAX_ACTIVITY_TEXT - 1).join("")}…`, truncated: true, }; } diff --git a/extensions/subagents/navigation.ts b/extensions/subagents/navigation.ts index 0f9729bc..171eb4fc 100644 --- a/extensions/subagents/navigation.ts +++ b/extensions/subagents/navigation.ts @@ -30,7 +30,9 @@ function cleanLine(value: string) { /** Normalize every title before it enters snapshots, artifacts, or the TUI. */ export function normalizeSubagentTitle(value: string, fallback = "subagent") { - return cleanLine(value).slice(0, 160) || fallback; + // Bound on code points: `slice` measures UTF-16 units and can end on the high + // half of a surrogate pair, which then reaches snapshots and the TUI broken. + return [...cleanLine(value)].slice(0, 160).join("") || fallback; } /** Prefer the newest running child, then the newest unread settled child. */ diff --git a/tests/extensions/background-terminals/watch.test.ts b/tests/extensions/background-terminals/watch.test.ts index cf021428..9ef4dc34 100644 --- a/tests/extensions/background-terminals/watch.test.ts +++ b/tests/extensions/background-terminals/watch.test.ts @@ -166,3 +166,16 @@ test("bounds the reported line when the stream has no newline", () => { assert.ok(hit); assert.ok(hit.line.length <= WATCH_LINE_MAX_CHARS + 1); }); + +test("bounds the reported line on code points instead of splitting a surrogate pair", () => { + const emoji = "\u{1F680}"; + const m = createChunkMatcher(compileWatchPattern("MATCH")); + const hit = m.push(`${"x".repeat(499)}${emoji}MATCH`, "stdout"); + assert.ok(hit); + + // The complete emoji survives the bound; a UTF-16 unit cut would have left a + // lone high surrogate in the transcript. The cap counts code points, so an + // astral character costs one position rather than two UTF-16 units. + assert.equal(hit.line, `${"x".repeat(499)}${emoji}\u2026`); + assert.ok([...hit.line].length <= WATCH_LINE_MAX_CHARS + 1); +}); diff --git a/tests/extensions/sessions/sessions.test.ts b/tests/extensions/sessions/sessions.test.ts index 04838c6b..cec9a981 100644 --- a/tests/extensions/sessions/sessions.test.ts +++ b/tests/extensions/sessions/sessions.test.ts @@ -191,3 +191,18 @@ test("session search matches formatted dates and years", () => { assert.equal(filterSessionEntries(entries, "2026").length, 1); assert.equal(filterSessionEntries(entries, "09-06").length, 1); }); + +test("session snippets bound on code points instead of splitting a surrogate pair", () => { + const emoji = "\u{1F680}"; + const bounded = buildSessionDescription( + { ...session, firstMessage: `${"a".repeat(18)}${emoji}tail` }, + 20, + ); + + // The full surrogate pair survives the bound; a UTF-16 unit cut would have + // left a lone high surrogate followed by the ellipsis. + assert.equal( + bounded.endsWith(`${"a".repeat(18)}${emoji}… — /tmp/project`), + true, + ); +}); diff --git a/tests/extensions/subagents/navigation.test.ts b/tests/extensions/subagents/navigation.test.ts index a779af37..9075b880 100644 --- a/tests/extensions/subagents/navigation.test.ts +++ b/tests/extensions/subagents/navigation.test.ts @@ -58,6 +58,16 @@ test("subagent titles are sanitized and bounded at ingress", () => { assert.equal(normalizeSubagentTitle("x".repeat(200)).length, 160); }); +test("subagent titles bound on code points instead of splitting a surrogate pair", () => { + const emoji = "\u{1F680}"; + // The complete emoji survives the bound; a UTF-16 unit cut would have left a + // lone high surrogate in the snapshot and the strip. + assert.equal( + normalizeSubagentTitle(`${"x".repeat(159)}${emoji}tail`), + `${"x".repeat(159)}${emoji}`, + ); +}); + test("strip selection prefers newest running, then newest unread settled", () => { const entries = [ snapshot("done", "done", 1, 5), diff --git a/tests/web/observer-registry.test.ts b/tests/web/observer-registry.test.ts index c0b93545..fae44a41 100644 --- a/tests/web/observer-registry.test.ts +++ b/tests/web/observer-registry.test.ts @@ -614,3 +614,20 @@ test("detail lookup is Session-scoped, exact, and fail-closed", () => { unregister(); } }); + +test("projects bounded activity text on code points instead of splitting a surrogate pair", () => { + const emoji = "\u{1F680}"; + const subagents = projectSubagentCapability([ + { + id: "sa-emoji", + title: `${"x".repeat(158)}${emoji}tail`, + status: "running", + createdAt: 1, + }, + ]); + + // The complete emoji survives the bound; a UTF-16 unit cut would have left a + // lone high surrogate in the bounded capability snapshot. + assert.equal(subagents.items[0]?.title, `${"x".repeat(158)}${emoji}…`); + assert.equal(subagents.truncated, true); +});