Skip to content

fix(sse): strip ANSI/VT100 codes from gc/ stream frames (#2273) - #2299

Open
anki1kr wants to merge 2 commits into
decolua:masterfrom
anki1kr:fix/gemini-cli-ansi-sse-sanitization-2273
Open

anki1kr wants to merge 2 commits into
decolua:masterfrom
anki1kr:fix/gemini-cli-ansi-sse-sanitization-2273

Conversation

@anki1kr

@anki1kr anki1kr commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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 in parseSSELine sees the ANSI byte (\x1b = 0x1B) instead of d (0x64) and returns null. 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.js

  • Exports stripAnsiCodes(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 line

open-sse/translator/response/gemini-to-openai.js

  • Strips ANSI from part.text before building the OpenAI delta chunk
  • Skips emitting an empty chunk when the entire text was control chars

Tests

tests/unit/gemini-cli-ansi-sanitization.test.js — 13 tests, all passing:

stripAnsiCodes:

  • CSI cursor/clear sequences stripped
  • SGR color codes stripped
  • OSC sequences stripped
  • Raw C0 controls stripped (except \t \n \r)
  • Normal printable text passes through unchanged
  • \t \n \r preserved
  • Empty string / null / undefined handled safely

parseSSELine with ANSI prefix:

  • ANSI-prefixed data line parsed correctly ✓
  • [DONE] with ANSI prefix parsed ✓
  • ANSI-only lines (no data payload) still dropped ✓
  • Normal lines (no ANSI) unaffected — no regression ✓

anki1kr added 2 commits July 2, 2026 16:59
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.
Copilot AI review requested due to automatic review settings July 2, 2026 12:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in parseSSELine() to recover ANSI-prefixed data: frames.
  • Sanitize gc/ streamed part.text output 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
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.

Malformed SSE stream for gemini-cli (gc/) models causes client parser crash

2 participants