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
This commit is contained in:
skywind 2026-06-18 14:29:52 +00:00 committed by Teknium
parent 9ed5aef1e6
commit 3e2fe3fcab
2 changed files with 58 additions and 7 deletions

View file

@ -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).

View file

@ -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