mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
@@ -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')
|
||||
})
|
||||
@@ -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 + '…'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user