fix(bridge): truncate derived session titles on grapheme boundaries (#1982)

* fix(bridge): truncate derived session titles on grapheme boundaries

deriveTitle cut the title with flat.slice(0, TITLE_MAX_LEN - 1), a
UTF-16 code-unit slice. When an emoji or astral-plane character in the
user's first message straddles the cut, the slice keeps its high
surrogate and drops the low one, leaving a lone surrogate. The title is
PATCHed to the claude.ai backend and UTF-8-serialized, so that lone
surrogate is transmitted as the U+FFFD replacement character and the
remote/mobile session list shows mojibake.

Route through truncateToWidth, the grapheme-safe helper deriveSessionTitle
in bridgeMain.ts already uses for the identical purpose.

* test(bridge): drop lookbehind from the lone-surrogate check

The source regex in initReplBridge.ts avoids lookbehinds to stay within
YARR/JSC (the engine Bun uses); mirror that in the test by matching an
unpaired low surrogate with a leading non-high-surrogate alternation
instead of a negative lookbehind.

* fix(bridge): bound the derived title in characters, not display width

TITLE_MAX_LEN caps the session-title API field in characters, but
truncateToWidth measures terminal columns. That charged 2 columns per wide
glyph, so 30 CJK characters — well inside the 50-char field — were cut to
24 plus an ellipsis, while zero-width graphemes cost 0 columns and removed
the cap entirely (100,000 code units passed through as a title).

Walk graphemes and accumulate against the code-unit length instead. That
keeps the surrogate pair and any combining marks intact, which is what the
original raw slice broke, while still enforcing the documented character
bound.
This commit is contained in:
0xfandom
2026-07-22 08:16:00 +08:00
committed by GitHub
parent 0a9bc187a4
commit a6b3d7a209
2 changed files with 92 additions and 4 deletions
@@ -0,0 +1,62 @@
import { expect, test } from 'bun:test'
import { deriveTitle } from './initReplBridge.js'
// The bridge derives a session title from the user's first REPL message and
// PATCHes it to the claude.ai backend, where it is JSON-serialized and
// UTF-8-encoded. A raw `.slice(0, N)` cut at a UTF-16 code-unit index can split
// an emoji or astral-plane character's surrogate pair, leaving a lone surrogate
// that goes over the wire as the U+FFFD replacement character.
// A high surrogate not followed by a low one (or a low surrogate not preceded
// by a high one) is an unpaired code unit — exactly what a mid-pair slice
// leaves behind. Written without a lookbehind to match the YARR/JSC constraint
// the source regex in initReplBridge.ts documents.
const LONE_SURROGATE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/
test('never emits a lone surrogate when truncating on an emoji boundary', () => {
// The 😀 (U+1F600, a surrogate pair) sits exactly where a 50-char slice would
// cut, so a raw slice keeps its high surrogate and drops the low one.
const raw = 'a'.repeat(48) + '😀😀😀 fix the login bug'
const title = deriveTitle(raw)!
expect(LONE_SURROGATE.test(title)).toBe(false)
// Round-tripping through UTF-8 (what axios sends) must not introduce U+FFFD.
expect(Buffer.from(title, 'utf8').toString('utf8')).not.toContain('')
})
test('truncates long single-line titles with an ellipsis', () => {
const title = deriveTitle('x'.repeat(200))!
expect(title.endsWith('…')).toBe(true)
expect(title.length).toBeLessThanOrEqual(50)
})
test('bounds the title in characters, not terminal columns', () => {
// TITLE_MAX_LEN caps the session-title API field in characters. A
// display-width measure charges 2 columns per wide glyph, so 30 CJK
// characters — comfortably inside the 50-char field — would be cut to 24
// plus an ellipsis and lose content.
const cjk = deriveTitle('你'.repeat(30))!
expect(cjk).toBe('你'.repeat(30))
expect(cjk.endsWith('…')).toBe(false)
})
test('still bounds input whose display width is zero', () => {
// The mirror failure: zero-width graphemes cost 0 columns, so a width-based
// cap never triggers and the full payload is PATCHed as the title.
const title = deriveTitle(''.repeat(100_000))!
expect(title.length).toBeLessThanOrEqual(50)
})
test('keeps a short title unchanged', () => {
expect(deriveTitle('fix the login bug')).toBe('fix the login bug')
})
test('returns undefined for a pure-tag / empty message', () => {
expect(deriveTitle('')).toBeUndefined()
})
test('collapses whitespace into a single-line title', () => {
expect(deriveTitle('fix\n\tthe login\nbug')).toBe('fix the login bug')
})
+30 -4
View File
@@ -36,6 +36,7 @@ import { logForDebugging } from '../utils/debug.js'
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
import { errorMessage } from '../utils/errors.js'
import { getBranch, getRemoteUrl } from '../utils/git.js'
import { getGraphemeSegmenter } from '../utils/intl.js'
import { toSDKMessages } from '../utils/messages/mappers.js'
import {
getContentText,
@@ -559,7 +560,8 @@ const TITLE_MAX_LEN = 50
* is empty (e.g. message was only <local-command-stdout>). Replaced by
* generateSessionTitle once Haiku resolves (~1-15s).
*/
function deriveTitle(raw: string): string | undefined {
// exported for testing
export function deriveTitle(raw: string): string | undefined {
// Strip <ide_opened_file>, <session-start-hook>, etc. — these appear in
// user messages when IDE/hooks inject context. stripDisplayTagsAllowEmpty
// returns '' (not the original) so pure-tag messages are skipped.
@@ -570,7 +572,31 @@ function deriveTitle(raw: string): string | undefined {
// Collapse newlines/tabs — titles are single-line in the claude.ai list.
const flat = firstSentence.replace(/\s+/g, ' ').trim()
if (!flat) return undefined
return flat.length > TITLE_MAX_LEN
? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026'
: flat
return truncateTitleToLength(flat, TITLE_MAX_LEN)
}
/**
* Truncate to at most `maxLen` UTF-16 code units without splitting a grapheme.
*
* TITLE_MAX_LEN bounds the session-title API field in characters, so the cut
* must be measured in code units — not terminal columns. A plain
* `slice(0, maxLen - 1)` cuts at an arbitrary code-unit index, so an emoji or
* astral-plane character straddling the boundary loses half its surrogate pair
* and the lone surrogate is transmitted as U+FFFD once the title is
* UTF-8-serialized to the claude.ai backend. Walking graphemes keeps the pair
* (and any combining marks) intact while still enforcing the character bound —
* a display-width measure would instead truncate wide scripts early and let
* zero-width input through unbounded.
*
* exported for testing
*/
export function truncateTitleToLength(text: string, maxLen: number): string {
if (text.length <= maxLen) return text
if (maxLen <= 1) return '…'
let result = ''
for (const { segment } of getGraphemeSegmenter().segment(text)) {
if (result.length + segment.length > maxLen - 1) break
result += segment
}
return result + '…'
}