fix(mcp): stop ask_trinity splitting surrogate pairs and losing sessions silently (#2027) - #2028
Open
dolho wants to merge 1 commit into
Open
fix(mcp): stop ask_trinity splitting surrogate pairs and losing sessions silently (#2027)#2028dolho wants to merge 1 commit into
dolho wants to merge 1 commit into
Conversation
…ons silently (#2027) Two edge cases from the `/edge-cases` pass over ent#328, both mirrored into `src/helper-mcp/src/client.ts`, which carries a byte-identical copy of the same code. **1. `truncate` cut on UTF-16 code units.** A boundary landing inside a surrogate pair left half of one, and an unpaired surrogate is ill-formed Unicode — a strict UTF-8 encoder or JSON-RPC client replaces it with U+FFFD or rejects the message. Reachability is low (an answer over 65,536 characters with a non-BMP character at exactly that offset), but the same helper truncates upstream error bodies at 200 characters, where arbitrary bytes are far more likely. Fixed by stepping back one unit off a high surrogate; only the final unit can be orphaned, so one check covers it, and the message still reports the configured limit rather than the adjusted cut. **2. The context-loss warning was gated on the response.** It lived inside `if (responseSessionId)`, so a 200 that answered but omitted `session_id` produced no warning at all — the caller silently lost its conversation context, which is precisely what that warning exists to prevent. The "different id returned" branch was covered and the neighbouring one was not. The warning is now decided by the REQUEST's session, with distinct wording for the two cases: "a new session was started" is false when none came back, and pointing a caller at a session that does not exist is worse than saying nothing. Two rows added to the existing `PARITY_CASES` table, and that is the part worth reading twice: mutating ONLY the helper copy changed nothing in the suite before this. The parity claim held for the cases in its table and no further, so the second copy was unverified. Both helper-only mutations now fail it. 11 new tests plus the 2 parity rows; every fix mutation-verified, on both copies. 146 pass, tsc clean. Closes #2027 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Resolve by running |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two edge cases in
ask_trinity(ent#328 / #1981), found by an/edge-casespass. Both fixed in both copies —src/helper-mcp/src/client.tscarries byte-identical code.1.
truncatesplit surrogate pairslength/slicecount UTF-16 code units, so a cut inside a surrogate pair leaves half of one. Proven by construction:An unpaired surrogate is ill-formed Unicode — a strict UTF-8 encoder or JSON-RPC client replaces it with U+FFFD or rejects the message.
Reachability on the answer path is low (>65,536 characters and a non-BMP char at exactly that offset). The error path is the real exposure: the same helper truncates upstream bodies at 200 characters, and those carry arbitrary bytes from a Google frontend on a cold start.
Fixed by stepping back one unit off a high surrogate — only the final unit can be orphaned, so one check covers it — with the message still reporting the configured limit rather than the adjusted cut.
2. A session-less response lost the caller's context silently
The warning lived inside
if (responseSessionId), so a 200 that answered but omittedsession_idproduced no warning at all. The caller silently lost its conversation context — precisely what that warning exists to prevent, per its own comment. The "different id returned" branch was covered; the neighbouring "no id returned" one was not.The warning is now decided by the request's session, with distinct wording for the two cases: "a new session was started" is false when none came back, and pointing a caller at a session that doesn't exist is worse than saying nothing.
The parity test only held for its own table
Worth reading twice, because it changes what that suite proves. After fixing both copies I mutated only the helper's
truncate— expecting the parity suite to fail. It passed, all 144.PARITY_CASEShad no over-long answer and no session-less response, so the "these two must agree" claim covered exactly the rows in its table and nothing else. The second copy was unverified.Two rows added. Both helper-only mutations now fail:
Test plan
ask_trinity_edges.test.ts— 11 tests: the straddling cut; the limit still reported as the ORIGINAL max; a fully-fitting pair surviving; a BMP-only answer still cut at exactly max (the step-back must not misfire); a short answer untouched; the 200-char error-path truncation; and all four session shapesPARITY_CASES, verified to catch a helper-only divergenceif→ 1 fail; same wording for both session cases → 2 failtsc --noEmitcleanNote
fast-checkstill isn't a dependency here and I didn't add one — the boundary case is exactly enumerable, so the deterministic tests are the property. A generator would explore more of the space but wouldn't have found anything these don't pin.Closes #2027