Skip to content

fix(mcp): stop ask_trinity splitting surrogate pairs and losing sessions silently (#2027) - #2028

Open
dolho wants to merge 1 commit into
devfrom
fix/2027-ask-trinity-edges
Open

fix(mcp): stop ask_trinity splitting surrogate pairs and losing sessions silently (#2027)#2028
dolho wants to merge 1 commit into
devfrom
fix/2027-ask-trinity-edges

Conversation

@dolho

@dolho dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Two edge cases in ask_trinity (ent#328 / #1981), found by an /edge-cases pass. Both fixed in both copiessrc/helper-mcp/src/client.ts carries byte-identical code.

1. truncate split surrogate pairs

return `${text.slice(0, max)}\n\n[output truncated at ${max} characters]`;

length/slice count UTF-16 code units, so a cut inside a surrogate pair leaves half of one. Proven by construction:

char at boundary: "\ud83d"   isLoneSurrogate: true

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 omitted session_id produced 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_CASES had 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:

helper-only mutation [surrogate] -> # pass 145 # fail 1
helper-only mutation [session]   -> # pass 145 # fail 1

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 shapes
  • 2 rows added to PARITY_CASES, verified to catch a helper-only divergence
  • Mutation-verified on the main copy: reverting the step-back → 2 fail; warning back inside the if → 1 fail; same wording for both session cases → 2 fail
  • 146 pass (was 133), tsc --noEmit clean

Note

fast-check still 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

…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>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant