From 3e2fe3fcab93532978e9e83187aa7e36878a1907 Mon Sep 17 00:00:00 2001 From: skywind Date: Thu, 18 Jun 2026 14:29:52 +0000 Subject: [PATCH] fix: close orphaned code fences on all send paths Adds `ensure_closed_code_fences()` helper to detect text with an odd count of triple-backtick markers (indicating an unclosed code block) and append a closing fence. Applies the fix to all four identified gap paths: G1: truncate_message early-break path for final chunk G2: _send_or_edit streaming edit path (most commonly hit) G3: overflow split first chunk (covered by G2's fix) G4: _send_fallback_final fallback send path Closes: #TBD --- gateway/platforms/base.py | 21 +++++++++++++++++- gateway/stream_consumer.py | 44 ++++++++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 0f08440d245f..2a6623abd91b 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -5931,7 +5931,26 @@ class BasePlatformAdapter(ABC): # Everything remaining fits in one final chunk if _len(prefix) + _len(remaining) <= max_length - INDICATOR_RESERVE: - chunks.append(prefix + remaining) + final_chunk = prefix + remaining + # Check fence balance: if carry_lang was set, the chunk + # starts with an opening fence. Walk the remaining text + # to see if the code block was closed; if not, close it. + _final_in_code = carry_lang is not None + _final_lang = carry_lang or "" + if _final_in_code: + for _line in remaining.split("\n"): + _stripped = _line.strip() + if _stripped.startswith("```"): + if _final_in_code: + _final_in_code = False + _final_lang = "" + else: + _final_in_code = True + _tag = _stripped[3:].strip() + _final_lang = _tag.split()[0] if _tag else "" + if _final_in_code: + final_chunk += FENCE_CLOSE + chunks.append(final_chunk) break # Find a natural split point (prefer newlines, then spaces). diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index dc4f5f956ac7..f37ef11b5469 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -42,13 +42,7 @@ logger = logging.getLogger("gateway.stream_consumer") # Sentinel to signal the stream is complete _DONE = object() - -# Sentinel to signal a tool boundary — finalize current message and start a -# new one so that subsequent text appears below tool progress messages. _NEW_SEGMENT = object() - -# Queue marker for a completed assistant commentary message emitted between -# API/tool iterations (for example: "I'll inspect the repo first."). _COMMENTARY = object() # Queue marker for a synchronous flush barrier. Enqueued as @@ -79,6 +73,34 @@ def escape_code_fences_for_display(text: str) -> str: return text.replace("```", "\\`\\`\\`") +def ensure_closed_code_fences(text: str) -> str: + """Append a closing `` ``` `` fence if the text has an odd number of + triple-backtick markers. + + When model output is truncated mid-code-block (e.g. by token limits + or a finish_reason="length"), the resulting message has an unclosed + code fence. On Discord, Slack, and other platforms this causes + everything after the orphaned fence to render as a single code block. + + The fix is trivial: count `` ``` `` occurrences. If odd, append a + closing fence on its own line. This is safe because nested + triple-backtick fences (e.g. a literal `` ``` `` inside a code block) + are exceedingly rare in model output and, when they do appear, the + extra closing fence just creates a brief empty code block at the end + of the message — far less harmful than the entire message being one + giant code block. + + Returns: + The input text with a closing fence appended if needed, or the + input text unchanged. + """ + if not isinstance(text, str) or not text: + return text + if text.count("```") % 2 == 1: + return text.rstrip("\n") + "\n```" + return text + + @dataclass class StreamConsumerConfig: """Runtime config for a single stream consumer instance.""" @@ -1119,6 +1141,10 @@ class GatewayStreamConsumer: Retries each chunk once on flood-control failures with a short delay. """ final_text = self._clean_for_display(text) + # Ensure balanced code fences before computing continuation, + # so the closing fence reaches the user even when the fallback + # only delivers the tail after mid-stream edits failed. + final_text = ensure_closed_code_fences(final_text) continuation = self._continuation_text(final_text) self._fallback_final_send = False if not continuation.strip(): @@ -1776,6 +1802,12 @@ class GatewayStreamConsumer: # Media files are delivered as native attachments after the stream # finishes (via _deliver_media_from_response in gateway/run.py). text = self._clean_for_display(text) + # Ensure code fences are balanced before send/edit. Model output + # truncated mid-code-block (e.g. finish_reason="length") leaves an + # orphaned ``` which, on Discord/Slack/Matrix, causes the entire + # remaining output to render as a single code block. This covers + # the streaming edit path (G2) and first-send path alike. + text = ensure_closed_code_fences(text) # A bare streaming cursor is not meaningful user-visible content and # can render as a stray tofu/white-box message on some clients. visible_without_cursor = text