extractSnippet + 6 echo paths slice UTF-16 code units without surrogate-pair awareness → lone surrogates → deterministic HTTP 500 on UTF-8 re-encoding gateways
Summary
extractSnippet() in src/server.ts slices content windows by UTF-16 code units with no surrogate-pair awareness. When a window boundary falls inside an astral character (e.g. an emoji), the echoed snippet contains a lone surrogate. Once such a snippet enters the conversation context, any model provider whose gateway re-encodes the request body to UTF-8 fails deterministically:
UnicodeEncodeError: 'utf-8' codec can't encode character '\udd16' in position 334437: surrogates not allowedwith HTTP 500 and an infinite retry loop — the poisoned message is re-sent verbatim on every retry, so the session never heals. (Apologies for writing the escape in indirection below: the literal sequence backslash-u-d-d-1-6 in this paragraph is the Python error text; rest of this report uses U+XXXX notation.)
Real-world incident (v1.0.169, 2026-09-15)
A ctx_batch_execute query echoed a snippet whose window start landed exactly between the two code units of (U+1F916 = U+D83E U+DD16). The snippet segment began with the lone low half U+DD16. Verified end-to-end:
- FTS match position for a query term: chunk code unit 1429
- Window start =
Math.max(0, 1429 - 300)= 1129 — exactly the low-half offset of in the stored chunk - The KB chunk itself is intact (7 complete , zero lone surrogates) — the chunker is code-point safe; only the echo splits the pair
- The lone surrogate survived
JSON.stringify(it serializes back to an escape) and was persisted by the client into its session store, bricking that session against Python-based gateways until the store was surgically repaired
The same day, an independent ctx_search echo produced the window-end variant: a segment ending with a lone high half (U+D83D) followed by the … suffix marker.
Affected sites (src/server.ts @ v1.0.169)
| Site | Behavior | Stranded half |
|---|---|---|
extractSnippet L1345-1366 window start (pos - WINDOW) |
prefix cut mid-pair | low half (segment starts with it) |
extractSnippet window end (pos + WINDOW, plus fitted-length cut) |
suffix cut mid-pair | high half + … |
extractSnippet L1336 (no-match prefix path) |
tail truncation | high half |
| L1482 (command echo, 500) | tail truncation | high half |
| L1515 (code echo, 2000) | tail truncation | high half |
| L2023 (preview, 120) | tail truncation | high half |
| L3637 (fetch preview, 384) | tail truncation | high half |
| L4204 (stderr echo, 200) | tail truncation | high half |
cli.ts L798 |
same family | high half |
All of these are plain String.slice/substring on code units.
Minimal repro
// extractSnippet semantics (WINDOW=300)
const q = 'scope';
const filler = 'a'.repeat(299);
const content = 'x'.repeat(800) + '\\\u{1F916}' + filler + q + ' y'.repeat(50);
// choose pos such that pos-300 lands exactly after the high half of the emoji:
const pos = content.indexOf(q);
const start = Math.max(0, pos - 300); // == indexOf('\\\u{1F916}') + 1 → mid-pair
const seg = (start > 0 ? '…' : '') + content.slice(start, start + 600) + '…';
JSON.stringify(seg); // contains a lone U+DD16 escape — will 500 any UTF-8 re-encoding gatewaySuggested fix
- Make every cut surrogate-aware: if
content.charCodeAt(cut - 1)is a high surrogate (0xD800–0xDBFF), move the cut back by one code unit. (For window starts, move forward past the low half or backward to include both halves.) src/truncate.tsalready ships code-point-safe helpers (charSafePrefix/byteSafePrefix) —extractSnippetand the echo paths listed above simply don't use them.- Add a regression test: for random emoji-dense content and random cut offsets, assert no code unit in 0xD800–0xDFFF appears unpaired in any echoed snippet / preview.
Impact
Any consumer whose provider gateway re-encodes request bodies to UTF-8 (notably Python-based gateways) gets a deterministic 500 per request, with retry loops that can never succeed — the failure is silent from the plugin's perspective (it only produces a "weird" string) and catastrophic downstream.
Source: mksglu/context-mode