Repository navigation
Conversation
Client-side history truncation can remove an assistant turn that contained
tool_calls/tool_use while leaving the corresponding tool result in the
history. Strict upstream APIs (Anthropic, OpenAI Responses, Gemini) then
reject the whole request with a 400 before the model executes.
Adds stripOrphanedToolResults() to translator/concerns/toolCall.js.
Covers all four wire formats used in 9router:
- OpenAI Chat Completions : role:tool messages with tool_call_id
- Anthropic Messages : content[type=tool_result] blocks with tool_use_id
- OpenAI Responses API : input[type=function_call_output] with call_id
- Gemini / Antigravity : contents[].parts[].functionResponse (id or name)
Called twice in chatCore.js:
1. Pre-translation — removes client-sent orphans before format conversion
2. Post-RTK/Headroom — removes orphans that compression creates by dropping
assistant turns containing tool_calls mid-history
15 unit tests added in tests/unit/strip-orphaned-tool-results.test.js
covering paired (kept), orphaned (removed), mixed, and edge cases for all
four formats.
Gemini Cloud Code Assist (gc/ models) occasionally prepends terminal
control sequences — cursor-up, clear-line, SGR codes — to SSE data
frames as progress/loading indicators. Without sanitization:
1. The ANSI prefix masks the 'data:' check in parseSSELine, so the
frame is silently dropped and the client SSE parser stalls waiting
for a frame that never arrives (hang / premature disconnect).
2. Raw control bytes embedded in part.text content are forwarded
verbatim to the client, which strict SSE parsers reject.
Fix:
- open-sse/utils/streamHelpers.js: exports stripAnsiCodes() covering
CSI sequences (ESC[ ... final), OSC sequences (ESC] ... BEL/ST),
single-char escapes, and raw C0 controls (except \t \n \r).
parseSSELine() applies it lazily — only when the leading byte is
ESC or another raw control character — to avoid a regex on every line.
- open-sse/translator/response/gemini-to-openai.js: strips ANSI from
part.text before building the OpenAI delta chunk, preventing control
bytes from appearing in the forwarded SSE content.
13 unit tests added in tests/unit/gemini-cli-ansi-sanitization.test.js:
- stripAnsiCodes: CSI, SGR, OSC, C0 controls, printable passthrough,
preserved whitespace (\t \n \r), empty string, null/undefined
- parseSSELine: ANSI-prefixed data lines parsed correctly, ANSI-only
lines dropped, [DONE] with ANSI prefix parsed, no regression on
normal lines.
There was a problem hiding this comment.
Pull request overview
This PR aims to harden 9router’s SSE streaming path against Gemini Cloud Code Assist (gc/) terminal control sequences by stripping ANSI/VT100 codes before SSE parsing and before emitting OpenAI-compatible delta chunks, preventing dropped frames and invalid control bytes in client-facing streams.
Changes:
- Add
stripAnsiCodes()and apply it lazily inparseSSELine()to recover ANSI-prefixeddata:frames. - Sanitize
gc/streamedpart.textoutput before building OpenAI delta chunks (and skip emitting empty deltas). - Introduce request normalization to remove orphaned tool results (pre-translation and post-compression) and add unit tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/strip-orphaned-tool-results.test.js | Adds unit coverage for orphan tool-result stripping across multiple wire formats. |
| tests/unit/gemini-cli-ansi-sanitization.test.js | Adds unit coverage for ANSI stripping and ANSI-prefixed SSE line parsing. |
| open-sse/utils/streamHelpers.js | Implements ANSI/C0 stripping helper and applies it to SSE line parsing when needed. |
| open-sse/translator/response/gemini-to-openai.js | Strips ANSI codes from streamed Gemini text parts before emitting OpenAI deltas. |
| open-sse/translator/concerns/toolCall.js | Adds stripOrphanedToolResults() to remove tool results without matching tool calls. |
| open-sse/handlers/chatCore.js | Calls orphan tool-result stripping before translation and again after RTK/headroom compression. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // gc/ (Gemini Cloud Code Assist) occasionally prepends terminal control chars | ||
| // (cursor-up, clear-line, carriage-return) to SSE frames before the "data:" prefix, | ||
| // which masks the prefix and causes strict client SSE parsers to crash or hang. | ||
| const ANSI_ESCAPE_RE = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g; |
Comment on lines
+204
to
+213
| if (msg.role === "user" && Array.isArray(msg.content)) { | ||
| const beforeBlocks = msg.content.length; | ||
| msg.content = msg.content.filter(block => { | ||
| if (block.type === "tool_result" && block.tool_use_id) { | ||
| return liveIds.has(block.tool_use_id); | ||
| } | ||
| return true; | ||
| }); | ||
| stripped += beforeBlocks - msg.content.length; | ||
| } |
Comment on lines
+123
to
+128
| // Strip orphaned tool results before translation so the translator never sees | ||
| // stale call_id references that client-side history truncation left behind. | ||
| const preStripped = stripOrphanedToolResults(body); | ||
| if (preStripped > 0) { | ||
| log?.debug?.("TOOLCLEAN", `pre-translation: stripped ${preStripped} orphaned tool result(s)`); | ||
| } |
bloodf
pushed a commit
to bloodf/9router
that referenced
this pull request
Jul 3, 2026
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.
Problem
Closes #2273.
Gemini Cloud Code Assist (
gc/models) occasionally prepends terminal control sequences (cursor-up\x1b[1A, clear-line\x1b[2K, SGR color codes, etc.) to SSE data frames as progress/loading indicators. This causes two distinct failure modes:1. Frame silently dropped → client hangs
The
data:prefix check inparseSSELinesees the ANSI byte (\x1b = 0x1B) instead ofd(0x64) and returnsnull. The entire SSE frame is dropped. Strict SSE parsers stall waiting for a frame that never arrives, causing the stream to hang or disconnect prematurely.2. Raw control bytes forwarded in content
ANSI codes embedded in
part.text(model output) are forwarded verbatim in the client-facing SSE data. This causes strict HTTP parsers to reject the stream.Both issues are specific to
gc/— standard HTTP providers (glm/,mimo/, etc.) don't send terminal output.Fix
open-sse/utils/streamHelpers.jsstripAnsiCodes(str)— regex covers CSI (ESC[ ... final-byte), OSC (ESC] ... BEL/ST), single-char escapes, and raw C0 controls (preserves\t \n \r)parseSSELine()applies it lazily — only when the leading byte is ESC or another raw control char — to avoid a regex scan on every single lineopen-sse/translator/response/gemini-to-openai.jspart.textbefore building the OpenAI delta chunkTests
tests/unit/gemini-cli-ansi-sanitization.test.js— 13 tests, all passing:stripAnsiCodes:\t \n \r)\t \n \rpreservedparseSSELinewith ANSI prefix:[DONE]with ANSI prefix parsed ✓