Files
aiturk-hermes-ide/gateway/stream_consumer.py
T

3617 lines
187 KiB
Python

"""Gateway streaming consumer — bridges sync agent callbacks to async platform delivery.
The agent fires stream_delta_callback(text) synchronously from its worker thread.
GatewayStreamConsumer:
1. Receives deltas via on_delta() (thread-safe, sync)
2. Queues them to an asyncio task via queue.Queue
3. The async run() task buffers, rate-limits, and progressively edits
a single message on the target platform
Design: Uses the edit transport (send initial message, then editMessageText).
This is universally supported across Telegram, Discord, and Slack.
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import inspect
import logging
import queue
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Optional
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
from gateway.platforms.base import _custom_unit_to_cp
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
from gateway.config import (
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
DEFAULT_STREAMING_CURSOR as _DEFAULT_STREAMING_CURSOR,
)
from gateway.response_filters import (
is_intentional_silence_response as _is_intentional_silence_response,
is_partial_silence_marker as _is_partial_silence_marker,
)
logger = logging.getLogger("gateway.stream_consumer")
# Sentinel to signal the stream is complete
_DONE = object()
_NEW_SEGMENT = object()
_COMMENTARY = object()
# Sentinel for tool-progress lines injected into the native stream bubble.
# Enqueued as ``(_TOOL_PROGRESS, line_text)`` by ``on_tool_progress()``.
_TOOL_PROGRESS = object()
# Authoritative turn-final payload, enqueued by ``finish(final_text=...)``
# just before ``_DONE``. Carries the completed ``final_response`` —
# including post-stream augmentation (file-mutation verifier footer,
# turn-completion explainer) — so the finalize/seal delivers the TRUE final
# and the recorded payload reconciles (#71643 / live finding #11: the
# footer-bearing final previously arrived only via a separate plain send).
_FINAL_TEXT = object()
# Queue marker for a synchronous flush barrier. Enqueued as
# ``(_FLUSH, threading.Event)``; the drain loop finalizes and delivers any
# buffered segment, then sets the event. A caller on the agent worker thread
# uses this (via ``flush_pending_sync``) to block until everything queued
# BEFORE the marker has actually landed on the platform — needed before
# sending a blocking interactive prompt (clarify poll) so the prompt is the
# last thing on screen, not racing ahead of buffered prose.
_FLUSH = object()
# Sentinel to signal an interaction boundary (approval prompt OR clarify
# decision prompt) — finalize the current stream, disable native streaming,
# and let post-interaction output go via send().
_APPROVAL_BOUNDARY = object()
# Sentinel to request an EAGER native re-seed after a clarify-reopen boundary.
# Posted the moment the user answers a clarify (before the LLM produces any
# post-answer delta), so the WeCom typing bubble reappears immediately instead
# of waiting for the first token. On WeCom, typing is driven by the stream
# seed frame (send_typing is a no-op), and the reopen path otherwise re-seeds
# lazily on the first delta — measured 48s of dead air in one turn. Handled
# serially in run(); see request_reopen_seed() and the run-loop handler.
_REOPEN_SEED = object()
# Default finalize text shown at an interaction boundary when no content has
# accumulated yet. Callers may override per-boundary (e.g. clarify passes its
# own) via close_for_approval_prompt(placeholder=...).
_DEFAULT_BOUNDARY_PLACEHOLDER = "⏸ 等待审批中..."
def escape_code_fences_for_display(text: str) -> str:
"""Escape triple-backtick markers so text can be safely wrapped
inside an outer ``` code block without breaking the fence.
When reasoning content contains ``` (e.g. the model quotes code
in its thinking), wrapping it in an outer ``` for display causes
the inner fence to break the outer block. Solution: replace each
`` ``` `` with `` \\`\\`\\` `` before wrapping.
Returns:
The input text with each `` ``` `` replaced by `` \\`\\`\\` ``,
or the input unchanged if no triple-backticks are present.
"""
if not isinstance(text, str) or "```" not in text:
return text
return text.replace("```", "\\`\\`\\`")
def ensure_closed_code_fences(text: str) -> str:
"""Append a closing `` ``` `` fence and/or `` ` `` if the text has
orphaned code-block or inline-code 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 same problem applies to inline-code spans closed by a single
backtick: an orphaned `` ` `` makes the remainder of the message
render as inline code.
Triple-backtick: 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.
Single backtick: after balancing triple-backtick fences, strip all
complete `` ```…``` `` regions and count remaining standalone `` ` ``.
If odd, append a closing inline-code backtick. Same trade-off: a
stray closing backtick may produce a brief empty inline-code span,
which is far less harmful than the rest of the message being rendered
as inline code.
Returns:
The input text with closing markers appended if needed, or the
input text unchanged.
"""
if not isinstance(text, str) or not text:
return text
# Step 1: fix triple-backtick code-block fences (existing logic)
if text.count("```") % 2 == 1:
text = text.rstrip("\n") + "\n```"
# Step 2: fix single-backtick inline-code spans
# Remove complete ```…``` regions so their internal backticks don't
# pollute the standalone count. Also remove any trailing unclosed
# ``` that leaks through (defence in depth).
import re
without_fences = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
without_fences = re.sub(r"```[^`]*$", "", without_fences)
if without_fences.count("`") % 2 == 1:
text = text + "`"
return text
@dataclass
class StreamConsumerConfig:
"""Runtime config for a single stream consumer instance."""
edit_interval: float = _DEFAULT_STREAMING_EDIT_INTERVAL
buffer_threshold: int = _DEFAULT_STREAMING_BUFFER_THRESHOLD
cursor: str = _DEFAULT_STREAMING_CURSOR
buffer_only: bool = False
# When >0, the final edit for a streamed response is delivered as a
# fresh message if the original preview has been visible for at least
# this many seconds. This makes the platform's visible timestamp
# reflect completion time instead of first-token time for long-running
# responses (e.g. reasoning models that stream slowly). Ported from
# openclaw/openclaw#72038. Default 0 = always edit in place (legacy
# behavior). The gateway enables this selectively per-platform.
fresh_final_after_seconds: float = 0.0
# Streaming transport selection:
# "auto" — prefer native draft streaming (e.g. Telegram sendMessageDraft)
# when the adapter + chat supports it; fall back to edit.
# "draft" — explicitly request native draft streaming; fall back to
# edit when unsupported.
# "edit" — progressive editMessageText (legacy/default behavior).
# "off" — handled by the gateway before the consumer is even built.
transport: str = "edit"
# Hint for the consumer about the originating chat type (e.g. "dm",
# "group", "supergroup", "forum"). Used to gate native draft streaming,
# which is platform-specific (Telegram drafts are DM-only).
chat_type: str = ""
class GatewayStreamConsumer:
"""Async consumer that progressively edits a platform message with streamed tokens.
Usage::
consumer = GatewayStreamConsumer(adapter, chat_id, config, metadata=metadata)
# Pass consumer.on_delta as stream_delta_callback to AIAgent
agent = AIAgent(..., stream_delta_callback=consumer.on_delta)
# Start the consumer as an asyncio task
task = asyncio.create_task(consumer.run())
# ... run agent in thread pool ...
consumer.finish() # signal completion
await task # wait for final edit
"""
# After this many consecutive flood-control failures, permanently disable
# progressive edits for the remainder of the stream.
_MAX_FLOOD_STRIKES = 3
# Reasoning/thinking tags that models emit inline in content.
# Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and
# run_agent.py _strip_think_blocks() tag variants.
_OPEN_THINK_TAGS = (
"<REASONING_SCRATCHPAD>", "<think>", "<reasoning>",
"<THINKING>", "<thinking>", "<thought>",
)
_CLOSE_THINK_TAGS = (
"</REASONING_SCRATCHPAD>", "</think>", "</reasoning>",
"</THINKING>", "</thinking>", "</thought>",
)
# Class-wide monotonic counter for native-streaming draft ids. Telegram
# animates a draft when the same draft_id is reused across consecutive
# calls in the same chat, so we need a fresh non-zero id per response.
#
# Seeded from a RANDOM process nonce, not zero and not the clock (PR
# 85796 review, B3 + r2 follow-up): draft_id is the wire identity for
# the relay connector's per-(channel, draft_id) sealed-stream
# tombstones, which outlive this process. Relay gateways are
# disposable by design (scale-to-zero), so a counter restarting at 1
# replays ids the connector already sealed — it then answers frames
# from the NEW turn out of the OLD tombstone (zero platform calls,
# old message identity) and the user's reply is silently dropped.
# An epoch-ms seed (the first fix) still collides on same-millisecond
# starts, forks, and clock steps; 49 random bits make collision
# probability negligible while keeping ids + realistic turn counts
# comfortably inside the connector's JS number range (2^53).
_draft_id_counter: int = secrets.randbits(49)
def __init__(
self,
adapter: Any,
chat_id: str,
config: Optional[StreamConsumerConfig] = None,
metadata: Optional[dict] = None,
on_new_message: Optional[callable] = None,
on_before_finalize: Optional[Callable[[], Any]] = None,
initial_reply_to_id: Optional[str] = None,
run_still_current: Optional[Callable[[], bool]] = None,
):
self.adapter = adapter
self.chat_id = chat_id
self.cfg = config or StreamConsumerConfig()
self.metadata = metadata
# Fired whenever a fresh content bubble is created on the platform
# (first-send of a new message, commentary, overflow chunk, or
# fallback continuation). The gateway uses this to linearize the
# tool-progress bubble: when content resumes after a tool batch,
# the next tool.started should open a NEW progress bubble below
# the content, not edit the old bubble above it.
# Called with no arguments. Exceptions are swallowed.
self._on_new_message = on_new_message
# Fired once when the stream transitions into its finalization path.
# Gateway callers use this to pause typing refreshes before a slow
# final rich-text edit (Telegram MarkdownV2 finalize, etc.).
self._on_before_finalize = on_before_finalize
self._initial_reply_to_id = initial_reply_to_id
# Per-turn identifier: uniquely identifies this consumer's stream turn.
# Passed to adapter.send_stream_frame() to prevent concurrent consumers
# from interfering with each other (e.g., /background, parallel subagents).
# Mirrors official wecom-openclaw-plugin's per-message streamId generation.
import uuid
self._turn_id = str(uuid.uuid4())
self._queue: queue.Queue = queue.Queue()
self._accumulated = ""
# Full segment text mirror of ``_accumulated`` that is NOT truncated
# when overflow splits seal head chunks. Used to record a reconciliable
# turn-final payload for multi-message deliveries (#78541).
self._stream_ledger = ""
self._message_id: Optional[str] = None
# Wall-clock timestamp (time.monotonic) when ``_message_id`` was
# first assigned from a successful first-send. Used by the
# fresh-final logic to detect long-lived previews whose edit
# timestamps would be stale by completion time. Ported from
# openclaw/openclaw#72038.
self._message_created_ts: Optional[float] = None
# Every real preview message id the consumer has put on screen during
# this response (first send + any continuation messages from oversized
# edits/sends). The fresh-final path deletes all of them when it
# re-delivers the completed answer as a single (rich) message, so a
# reply that was split across the platform's edit limit while streaming
# doesn't leave stale fragments above the final message.
self._preview_message_ids: "set[str]" = set()
# IDs from only the active text segment. A tool boundary preserves
# the run-wide set for fresh-final bookkeeping, but a failure recovery
# must never delete an earlier finalized preamble/commentary message.
self._segment_preview_message_ids: "set[str]" = set()
self._already_sent = False
self._edit_supported = True # Disabled when progressive edits are no longer usable
self._last_edit_time = 0.0
self._last_sent_text = "" # Track last-sent text to skip redundant edits
# True when the most recent _send_or_edit split-and-delivered across
# continuation messages (the adapter adopted a new message id).
self._last_edit_overflowed = False
self._fallback_final_send = False
self._fallback_prefix = ""
# True when fallback is sending only the missing tail after a partial
# Telegram overflow delivery. In that case the already-visible prefix
# is intentional content, not a stale preview to delete.
self._fallback_preserve_partial_messages = False
# Keep fallback recovery responsive. Telegram's adapter already bounds
# edit retries at five seconds; a final-delivery fallback must not hold
# the stream task through a longer flood cooldown before retrying.
self._max_fallback_flood_retry_seconds = 5.0
self._flood_strikes = 0 # Consecutive flood-control edit failures
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
self._final_response_sent = False
# Set when the final response content was sent to the user via
# streaming, even if the final edit (cursor removal etc.)
# subsequently failed.
self._final_content_delivered = False
# Exact cleaned payload of the turn-final delivery that set the flags
# above. The gateway compares this against the completed
# ``final_response`` before trusting the flags: a *successful* finalize
# edit that carried only a stale preview snapshot must not suppress the
# complete send (#71643). ``None`` means "no record" — legacy trust,
# so paths that predate the record keep their behavior.
self._delivered_final_text: Optional[str] = None
# True when the current turn's answer was delivered across multiple
# sealed messages (overflow split / adapter continuation adoption).
# When a payload was recorded (via ``_stream_ledger`` /
# ``_record_turn_final_payload``), ``delivered_final_matches`` can still
# reconcile. Payload-less split delivery must NOT inherit legacy trust
# (#78541) — that combination was swallowing complete Telegram group
# replies after an early/partial multi-message delivery.
self._turn_split_delivery = False
# True when a full-final send timed out in a way that MAY have reached
# the platform (``_send_empty_fallback_final`` → "ambiguous"). The
# only case where a payload-less delivery flag keeps legacy trust in
# ``delivered_final_matches`` (#95382 tightening) — re-sending there
# risks a duplicate rather than recovering a loss.
self._delivery_ambiguous = False
self._delivered_commentary_texts: list[str] = []
# Retains the finalized visible text of each streaming segment so
# ``has_delivered_text`` can still match after ``_reset_segment_state``
# clears ``_last_sent_text``. Without this, a segment break (triggered
# by ``on_segment_break`` or ``on_commentary``) erases the only record
# of what was delivered, and the gateway's final-send suppression
# can't recognize an already-delivered response. (#65919 review)
self._delivered_segment_texts: list[str] = []
# Cache adapter lifecycle capability: only platforms that need an
# explicit finalize call (e.g. DingTalk AI Cards) force us to make
# a redundant final edit. Everyone else keeps the fast path.
# Use ``is True`` (not ``bool(...)``) so MagicMock attribute access
# in tests doesn't incorrectly enable this path.
self._adapter_requires_finalize: bool = (
getattr(adapter, "REQUIRES_EDIT_FINALIZE", False) is True
)
# Session staleness guard — when set to False (e.g. after /new or
# /stop), the run() loop will abandon the stream early instead of
# continuing to edit and deliver stale deltas.
self._run_still_current = run_still_current or (lambda: True)
# Think-block filter state (mirrors CLI's _stream_delta tag suppression)
self._in_think_block = False
self._think_buffer = ""
# Native draft-streaming state. Resolved at the start of run() based
# on cfg.transport, cfg.chat_type, and the adapter's
# supports_draft_streaming() probe. When True, the consumer emits
# animated draft frames via adapter.send_draft instead of progressive
# edits via adapter.edit_message. The final answer still goes
# through the normal first-send path so the user gets a real message
# in their chat history (drafts have no message_id).
self._use_draft_streaming = False
self._draft_id: Optional[int] = None
# Cumulative draft-frame failure count for this consumer. After the
# first failure we permanently disable drafts for the remainder of
# this response and route through edit-based for graceful degradation.
self._draft_failures = 0
self._before_finalize_notified = False
# Native streaming transport (e.g. WeCom msgtype: "stream"). Unlike
# drafts, native streaming is the *only* delivery channel for the
# turn — first frame, mid-stream updates, and the final answer all
# flow through ``adapter.send_stream_frame()`` and the adapter
# manages the stream lifecycle (init → cumulative updates →
# finish=true). Resolved at the start of run() and disabled on
# any failure so the consumer falls back to edit/send.
self._use_native_streaming = False
# Tracks whether the native stream bubble has been opened (seed frame sent).
# Used in fallback logic to decide if we need to finalize the stream before
# falling back to send(). Set to True after seed frame succeeds, even though
# seed has zero visible content.
self._native_stream_opened = False
# Number of visible characters last successfully pushed to the
# native stream. Used for "send only when enough new content has
# accumulated" throttling so we don't spam frames at WeCom's
# 30 frames/min rate ceiling.
self._native_last_pushed_len = 0
# Finalize text used at an interaction boundary (approval/clarify) when
# no content has accumulated yet. Set by close_for_approval_prompt();
# defaults to the approval wording for backward compatibility.
self._boundary_placeholder = _DEFAULT_BOUNDARY_PLACEHOLDER
# Human-readable label for the current interaction boundary, used only
# for log prefixes so a clarify boundary doesn't log as "Approval".
# Set by close_for_approval_prompt(); race-free because boundaries are
# processed serially.
self._boundary_reason = "Approval"
# When True, the interaction boundary finalizes the current stream but
# KEEPS native streaming enabled so post-prompt output re-opens a fresh
# native stream (via the lazy re-seed in _send_or_edit) instead of
# degrading to a one-shot send(). Clarify sets this (short waits, low
# stream-staleness risk); approval leaves it False (long, unbounded
# waits — the stream may go stale, so send() is safer). Set by
# close_for_approval_prompt(); race-free (boundaries are serial).
self._boundary_reopen = False
# Marks that a boundary asked to reopen the native stream but no
# post-prompt content has re-seeded it yet. Guards got_done from
# re-seeding a fresh stream just to emit a lone "✅" placeholder when
# the agent produced nothing after the prompt.
self._awaiting_reopen_after_boundary = False
# Marks that an EAGER re-seed (via _REOPEN_SEED) already opened a fresh
# native stream after a clarify answer, BEFORE any post-answer content.
# Unlike the lazy path, the typing bubble is already on screen, so
# got_done must actively finalize it (not silently skip) when the agent
# produces no content — otherwise a blank typing bubble hangs forever.
self._reopen_seeded_eagerly = False
# Tool-progress overlay state (native streaming only).
# Lines are injected via on_tool_progress() and displayed as a
# temporary overlay in the stream bubble until real text arrives.
self._tool_progress_lines: list[str] = []
self._tool_progress_active: bool = False
def _stream_is_message(self) -> bool:
"""Whether THIS chat's transport treats the stream as the message.
Prefers the adapter's per-chat probe (multi-platform relay: one
adapter fronts N platforms, and the class attribute can only
reflect the primary identity — review r2, finding 2). Falls back
to the legacy attribute for adapters without the probe. Both are
resolved on the CLASS to stay MagicMock-safe (auto-created
instance attributes are truthy).
"""
probe = getattr(type(self.adapter), "stream_is_message_for_chat", None)
if callable(probe):
try:
return probe(self.adapter, str(self.chat_id)) is True
except Exception:
return False
return getattr(self.adapter, "draft_stream_is_message", False) is True
@property
def accepts_tool_progress(self) -> bool:
"""Whether this consumer can absorb tool progress into its stream.
True only when native streaming is resolved and active. Callers use
this to decide the progress routing path (in-stream vs progress_queue).
"""
return self._use_native_streaming
def on_tool_progress(self, line: str) -> None:
"""Inject a tool-progress status line into the native stream bubble.
Thread-safe (called from agent worker thread via queue.Queue). Only
meaningful when native streaming is active — callers should gate on
``accepts_tool_progress``.
The line is displayed as an overlay until the next text delta arrives,
at which point real content overwrites the tool-progress lines.
"""
if line:
self._queue.put((_TOOL_PROGRESS, line))
def _compose_frame_content(self) -> str:
"""Compose the current frame content for native streaming.
Strategy B: when both accumulated text and tool-progress lines exist,
append tool lines below the text separated by a horizontal rule.
On finalize, only accumulated text is sent (no tool lines).
"""
if self._accumulated and self._tool_progress_lines:
# Text + active tool status at the bottom
return self._accumulated + "\n\n---\n" + "\n".join(self._tool_progress_lines)
elif self._accumulated:
return self._accumulated
elif self._tool_progress_lines:
return "\n".join(self._tool_progress_lines)
return ""
def _metadata_for_send(
self,
*,
final: bool = False,
expect_edits: bool = False,
) -> dict | None:
"""Return per-send metadata for stream-created messages.
Mattermost treats notify-worthy sends as user-visible final content
when deciding whether a broken thread root may fall back flat. Preview
and progress sends keep their original metadata and remain thread-strict.
``expect_edits`` preserves the upstream Telegram streaming contract:
preview messages that may be edited later must stay on the editable
legacy send path, while fresh/fallback final sends can still use richer
final-message delivery.
"""
meta = dict(self.metadata) if self.metadata else {}
if self._initial_reply_to_id:
meta["reply_to_message_id"] = self._initial_reply_to_id
if expect_edits:
meta["expect_edits"] = True
if final:
meta["notify"] = True
return meta or None
@property
def already_sent(self) -> bool:
"""True if at least one message was sent or edited during the run."""
return self._already_sent
@property
def final_response_sent(self) -> bool:
"""True when the stream consumer delivered the final assistant reply."""
return self._final_response_sent
@property
def message_id(self) -> str | None:
"""The Discord/chat message ID of the last-sent or edited message."""
return self._message_id
@property
def final_content_delivered(self) -> bool:
"""True when the final response content reached the user, even if
the subsequent cosmetic edit (cursor removal) failed."""
return self._final_content_delivered
async def _notify_before_finalize(self) -> None:
"""Run the pre-finalize hook exactly once, swallowing hook errors."""
if self._before_finalize_notified:
return
self._before_finalize_notified = True
if self._on_before_finalize is None:
return
try:
result = self._on_before_finalize()
if inspect.isawaitable(result):
await result
except Exception:
pass
async def _edit_message(
self,
*,
message_id: str,
content: str,
finalize: bool = False,
):
"""Edit via the adapter, passing routing metadata when supported."""
kwargs = {
"chat_id": self.chat_id,
"message_id": message_id,
"content": content,
}
# Keep the long-standing stream-consumer contract: concrete adapters
# must accept finalize= even when it is False (guarded by tests).
kwargs["finalize"] = finalize
if self.metadata:
try:
params = inspect.signature(self.adapter.edit_message).parameters
if "metadata" in params or any(
param.kind is inspect.Parameter.VAR_KEYWORD
for param in params.values()
):
kwargs["metadata"] = self.metadata
except (TypeError, ValueError):
pass
return await self.adapter.edit_message(**kwargs)
def _append_accumulated(self, text: str) -> None:
"""Append to the live buffer and the split-stable stream ledger."""
if not text:
return
# New text delta arriving: clear tool-progress overlay so the next
# frame shows real content (Strategy B: text overwrites tool lines).
if self._tool_progress_lines:
self._tool_progress_lines.clear()
self._tool_progress_active = False
self._accumulated += text
self._stream_ledger += text
def _mark_skip_redundant_finalize(self) -> None:
"""Mark the turn final as delivered by a prior mid-stream edit.
Used by the run loop when the final accumulated content was already
delivered by the last visible edit and the explicit finalize edit is
skipped. Records what was actually ACKED on the wire, not what was
accumulated: a throttled edit-transport stream can reach this state
with the last acked edit still holding an earlier preview snapshot
(cursor-suffixed). Recording ``_accumulated`` would let a frozen
preview reconcile as the delivered final and suppress the corrective
send, leaving the user a cut-off message. The multi-message split
path keeps its ledger substitution inside
``_record_turn_final_payload``.
"""
self._final_response_sent = True
self._final_content_delivered = True
acked = self._last_sent_text or self._accumulated
if self.cfg.cursor and acked.endswith(self.cfg.cursor):
acked = acked[: -len(self.cfg.cursor)]
self._record_turn_final_payload(acked)
def _record_turn_final_payload(self, text: str) -> None:
"""Record what the user has actually seen as this turn's final answer.
Normalized the same way ``_send_or_edit`` normalizes outgoing text
(media-directive strip + fence closing) so the gateway can compare it
against the completed ``final_response`` (#71643).
``text`` is what the *calling* path just delivered. On a multi-message
split that is only the trailing chunk — the overflow paths truncate
``_accumulated`` once head chunks are sealed — so ``_stream_ledger``
(the un-truncated segment text) is preferred there and ``text`` is
ignored. Without that substitution a split turn records a tail-only
payload, which the gateway reads as a mismatch and re-sends on top of
an answer the user already received (#78541).
"""
source = text or ""
if self._turn_split_delivery and self._stream_ledger:
source = self._stream_ledger
self._delivered_final_text = ensure_closed_code_fences(
self._clean_for_display(source)
).strip()
def delivered_final_matches(self, final_text: str) -> Optional[bool]:
"""Reconcile the recorded turn-final payload against ``final_text``.
Returns a tri-state verdict for the gateway's suppression decision
(#71643 — a *successful* finalize edit can still carry only a stale
preview snapshot, so call success alone must not confirm delivery):
- ``True`` — the recorded turn-final payload (or a previously
delivered segment/commentary) matches ``final_text``; suppressing
the normal final send is safe.
- ``False`` — a turn-final delivery was recorded but its payload
demonstrably differs from ``final_text``, OR this was a
payload-less multi-message split delivery (#78541) whose flag
alone must not suppress the normal final send.
- ``None`` — no payload comparison is possible on a non-split
legacy/uncertain path that recorded nothing. The caller keeps
the pre-existing flag-trusting behavior so ambiguous-timeout
dedup is not regressed.
"""
target = ensure_closed_code_fences(
self._clean_for_display(final_text or "")
).strip()
if not target:
return None
if self._delivered_final_text is None:
if self._turn_split_delivery:
# #78541: refuse legacy trust for payload-less split delivery.
return False
# #95382 / #98552 class fix: a delivery flag with NO recorded
# payload must still be judged against the FINAL content, not
# trusted blindly. Every internal flag-setting site records a
# payload; a record-less consumer whose visible/streamed text
# does not contain the completed response has demonstrably NOT
# delivered it (first-edit prefix, mid-stream truncation) — the
# flag alone must not suppress the corrective send.
# ``_already_sent`` gates the visible-text match: draft frames
# set ``_last_sent_text`` for dedupe but are ephemeral (they
# deliberately do not set ``_already_sent``), so draft-only
# visibility must not count as durable delivery.
if self._already_sent and self.has_delivered_text(final_text):
return True
# The one legitimately ambiguous case keeps legacy trust: a
# timed-out full-final send may have reached the platform
# (``_send_empty_fallback_final`` → "ambiguous"), so re-sending
# risks a duplicate. That site marks itself explicitly.
if self._delivery_ambiguous:
return None
return False
if self._delivered_final_text.strip() == target:
return True
# A segment break / commentary may have delivered the final text
# earlier in the turn under a different record.
if self.has_delivered_text(final_text):
return True
return False
def has_delivered_text(self, text: str) -> bool:
"""Return True if *text* was already delivered as visible chat content."""
target = self._clean_for_display(text or "").strip()
if not target:
return False
visible_prefix = self._visible_prefix().strip()
if visible_prefix == target:
return True
return any(
sent.strip() == target
for sent in (*self._delivered_commentary_texts, *self._delivered_segment_texts)
)
def on_segment_break(self) -> None:
"""Finalize the current stream segment and start a fresh message."""
self._queue.put(_NEW_SEGMENT)
def close_for_approval_prompt(
self,
placeholder: str | None = None,
reason: str = "Approval",
reopen: bool = False,
) -> asyncio.Future:
"""Signal an interaction boundary — finalize stream, then either disable
native (approval) or keep it for a fresh re-opened stream (clarify).
Used for any mid-stream interaction that must not keep updating the
current native-stream bubble: a dangerous-command approval prompt or a
clarify decision prompt. Queues a boundary signal that the consumer
processes serially: finalize the current stream with accumulated text
(creating a stable message for pre-prompt content), then handle
post-prompt output per ``reopen``.
``placeholder`` is the finalize text used only when there is no
accumulated content yet (the prompt fired as the agent's first action).
Defaults to the approval placeholder; clarify passes its own so the
finalized bubble doesn't read "waiting for approval" for a question.
``reason`` is a human-readable label ("Approval"/"Clarify") used only
for the boundary handler's log prefixes so a clarify boundary doesn't
surface as an "Approval boundary" failure during troubleshooting.
``reopen`` controls post-prompt delivery. False (approval): disable
native streaming and buffer post-prompt output into a single reliable
send() — approval waits are long and unbounded, so the stream may go
stale. True (clarify): keep native streaming enabled so post-prompt
output re-opens a fresh native stream via the existing lazy re-seed,
restoring the typing-bubble experience; if the re-seed later fails the
consumer degrades to send() automatically.
Returns a (Future, cancelled_flag) tuple. The Future resolves True
when the boundary has been processed. cancelled_flag is included
for backward compatibility with callers that set it on timeout;
the boundary handler no longer reads it (finalize always runs).
For platforms without native streaming this is a no-op (returns
an immediately-resolved Future).
Called from sync context (agent/approval thread). The boundary
is processed by the consumer's async run() task, ensuring no
race conditions with pending deltas or other queue items.
"""
loop = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
pass
if not self._use_native_streaming:
# No native stream to close — return resolved future
f = asyncio.Future() if loop else concurrent.futures.Future()
f.set_result(True)
return f
# Stash the empty-content placeholder, log label, and reopen mode for
# the serial boundary handler. Boundaries are processed one at a time,
# so instance attributes are race-free and keep the queue signal shape
# unchanged.
self._boundary_placeholder = placeholder or _DEFAULT_BOUNDARY_PLACEHOLDER
self._boundary_reason = reason or "Approval"
self._boundary_reopen = bool(reopen)
# Create a future that run() will resolve after processing.
# cancelled_flag is retained for backward compatibility with callers
# (run.py sets it on timeout) but the handler always finalizes regardless.
if loop:
boundary_future = loop.create_future()
else:
boundary_future = concurrent.futures.Future()
cancelled_flag = {"cancelled": False}
self._queue.put((_APPROVAL_BOUNDARY, boundary_future, cancelled_flag))
return boundary_future, cancelled_flag
def on_commentary(self, text: str) -> None:
"""Queue a completed interim assistant commentary message."""
if text:
self._queue.put((_COMMENTARY, text))
def flush_pending_sync(self, timeout: float = 5.0) -> bool:
"""Block the calling (agent worker) thread until everything queued
before this point has been finalized and delivered to the platform.
Enqueues a ``(_FLUSH, Event)`` barrier behind any pending deltas /
commentary / segment breaks. The async ``run()`` task processes those
first (FIFO), then handles the barrier — finalizing the current segment
and setting the event. Returns True if the flush completed within
``timeout``, False on timeout (so the caller continues rather than
hanging if the consumer task is not running / already finished).
This is the ordering barrier used before sending a blocking interactive
prompt (clarify poll): without it, the poll — sent on a separate,
agent-thread-blocking path — races ahead of buffered prose that is still
sitting in this queue, so the question lands ABOVE its own explanation.
"""
evt = threading.Event()
try:
self._queue.put((_FLUSH, evt))
except Exception:
return False
return evt.wait(timeout=max(0.0, float(timeout)))
def request_reopen_seed(self) -> None:
"""Request an EAGER native re-seed after a clarify-reopen boundary.
Called (thread-safe, like on_commentary / close_for_approval_prompt)
the instant the user answers a clarify — BEFORE the LLM emits any
post-answer delta. Posts _REOPEN_SEED so run() immediately sends an
empty seed frame, which is what makes the WeCom typing bubble reappear
without waiting for the first token (measured 48s of dead air otherwise).
No-op unless we're in the reopen-pending state on a native stream: only
after a clarify boundary (`_awaiting_reopen_after_boundary`) with native
still enabled and no stream currently open. This keeps a stray call from
opening a spurious bubble mid-stream or on the approval path.
"""
if (
self._use_native_streaming
and self._awaiting_reopen_after_boundary
and not self._native_stream_opened
):
self._queue.put(_REOPEN_SEED)
def _notify_new_message(self) -> None:
"""Fire the on_new_message callback, swallowing any errors."""
cb = self._on_new_message
if cb is None:
return
try:
cb()
except Exception:
logger.debug("on_new_message callback error", exc_info=True)
@staticmethod
def _signal_flush(flush_event) -> None:
"""Wake a thread blocked in flush_pending_sync(), swallowing errors.
Centralised so every loop exit path that consumed a ``_FLUSH`` barrier
(the normal bottom-of-iteration path AND early ``continue`` paths such
as the oversized-prose overflow split) reliably sets the event. Missing
a set is not a deadlock — the caller uses a bounded timeout — but it
would make the caller stall the full timeout before its blocking send.
"""
if flush_event is None:
return
try:
flush_event.set()
except Exception:
pass
def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None:
if preserve_no_edit and self._message_id == "__no_edit__":
return
# Retain the finalized visible text of the current segment before
# clearing ``_last_sent_text``, so ``has_delivered_text`` can still
# match it after a segment break. (#65919 review)
if self._last_sent_text:
finalized = self._clean_for_display(self._last_sent_text).strip()
if finalized:
self._delivered_segment_texts.append(finalized)
self._message_id = None
self._message_created_ts = None
self._accumulated = ""
self._stream_ledger = ""
self._last_sent_text = ""
self._fallback_final_send = False
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
self._segment_preview_message_ids = set()
# Tool-progress overlay: clear on segment reset so a new segment
# starts clean.
self._tool_progress_lines = []
self._tool_progress_active = False
# #29346: a tool/segment boundary means what we delivered was an interim
# preamble, not the final answer — clear the flags so a premature setter
# can't fool the gateway. Safe: got_done returns before any reset, and
# run.py reads these only after the consumer task exits.
self._final_response_sent = False
self._final_content_delivered = False
self._delivered_final_text = None
self._delivery_ambiguous = False
self._turn_split_delivery = False
# Native draft streaming: bump the draft_id so the next text segment
# animates as a fresh preview below the tool-progress bubbles, not
# over the prior segment's already-finalized draft. This is how
# we avoid the "inter-tool-call text leak" failure mode openclaw
# documented in their issue #32535 — each text block becomes its
# own visible message via the finalize, then a new draft animates
# for the next one.
if self._use_draft_streaming:
# Finding #4 (live canary, Alice): for stream-is-the-message
# adapters (relay Slack native streaming), a draft_id bump opens
# a brand-new platform stream per tool boundary — the user saw
# one frozen message per segment (each stuck with the streaming
# cursor, never sealed) plus the real final. Those adapters keep
# ONE stream per turn: tool progress lives in the native task
# card, and the connector's suffix-delta logic appends each new
# segment cleanly (prefix mismatch → whole-segment append).
# Telegram-shaped drafts (clear + separate final) keep the bump.
if not self._stream_is_message():
type(self)._draft_id_counter += 1
self._draft_id = type(self)._draft_id_counter
async def _handle_approval_boundary(self, boundary_future, cancelled_flag=None) -> None:
"""Process an approval boundary: finalize stream, disable native for post-approval.
This method is called serially from run() when _APPROVAL_BOUNDARY is dequeued.
Strategy: finalize the current stream with accumulated text (creating a
stable message for pre-approval content), then disable native streaming
so post-approval output goes through the reliable send() path.
Why not keep the stream open across approval:
- WeCom stream finalize ack only confirms server receipt, not client render.
- Approval waits introduce an idle gap where the stream may become stale
on the client side (no server-side 846608, but client stops tracking it).
- If content_delivered=True but the client didn't render, the normal
final send is suppressed → user sees nothing.
- Approval is a natural interaction boundary; "pre-approval preamble" +
"post-approval result" as two messages is acceptable UX.
Post-approval output uses regular send() which is unconditionally reliable.
"""
# Log label ("Approval"/"Clarify") so a clarify boundary failure doesn't
# surface as an "Approval boundary" error during troubleshooting.
_reason = getattr(self, "_boundary_reason", "Approval") or "Approval"
delivery_failed = False
try:
if self._native_stream_opened:
# Finalize current stream with accumulated content.
# This converts the typing bubble into a stable message.
finalize_text = self._accumulated or self._boundary_placeholder
finalize_ok = False
try:
result = await self.adapter.send_stream_frame(
finalize_text,
finalize=True,
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
finalize_ok = bool(result)
except Exception as e:
logger.warning("%s boundary: finalize failed: %s", _reason, e)
if not finalize_ok:
# Stream finalize didn't land — the typing bubble may still
# be showing partial content. Fallback: deliver the pre-prompt
# text via reliable send() so the user at least sees it.
logger.warning(
"%s boundary: finalize not confirmed, "
"falling back to send() for pre-prompt text (chat=%s)",
_reason, self.chat_id,
)
fallback_ok = False
try:
send_result = await self.adapter.send(
self.chat_id, finalize_text,
)
fallback_ok = getattr(send_result, "success", False)
except Exception as send_err:
logger.warning(
"%s boundary: fallback send also failed: %s",
_reason, send_err,
)
if not fallback_ok:
# Both finalize and fallback failed — pre-prompt text
# may be lost. Mark boundary as failed so the caller knows.
logger.error(
"%s boundary: both finalize and fallback send failed "
"(chat=%s) — pre-prompt text may not have been delivered",
_reason, self.chat_id,
)
delivery_failed = True
else:
logger.debug(
"%s boundary: finalized stream (chat=%s, turn=%s)",
_reason, self.chat_id, self._turn_id,
)
if self._boundary_reopen:
# Clarify boundary: KEEP native streaming enabled. The current
# stream was finalized above (pre-prompt content is now a stable
# bubble); marking it closed makes the next post-prompt delta
# re-open a fresh native stream via the lazy re-seed in
# _send_or_edit, restoring the typing-bubble experience. Do NOT
# set buffer_only — post-prompt output should stream, not batch.
# If the re-seed later fails, the consumer degrades to send()
# on its own. _awaiting_reopen_after_boundary guards got_done
# from re-seeding a stream just to emit a lone "✅" when the
# agent produced no post-prompt content.
self._native_stream_opened = False
self._native_last_pushed_len = 0
self._awaiting_reopen_after_boundary = True
self._reset_segment_state()
# INFO (temporary latency probe): boundary finalize is done and
# the old bubble is closed. From here the consumer waits for
# the LLM's first post-answer delta before re-seeding the C
# bubble — so the gap between THIS line and the
# "Re-opened native stream" INFO below is exactly the
# "typing slow to reappear after clarify" delay.
logger.info(
"[latency] Clarify boundary finalized, awaiting first "
"post-answer delta to re-seed (chat=%s, turn=%s)",
self.chat_id, self._turn_id,
)
else:
# Approval boundary: disable native streaming for post-approval
# output, which goes through regular send() (unconditionally
# reliable, no client-side stream state dependency). Set
# buffer_only=True so the consumer accumulates all post-approval
# text and delivers it in one shot on got_done, avoiding
# mid-stream flushes that would create multiple messages on
# non-editable platforms like WeCom.
self._use_native_streaming = False
self._native_stream_opened = False
self._native_last_pushed_len = 0
self.cfg.buffer_only = True
# Reset segment state so post-approval output starts fresh via send().
self._reset_segment_state()
boundary_ok = not delivery_failed
except Exception as e:
logger.warning("%s boundary processing failed: %s", _reason, e)
boundary_ok = False
finally:
# Resolve future so approval callback knows the result
if boundary_future is not None:
try:
if isinstance(boundary_future, asyncio.Future):
if not boundary_future.done():
boundary_future.set_result(boundary_ok)
elif isinstance(boundary_future, concurrent.futures.Future):
if not boundary_future.done():
boundary_future.set_result(boundary_ok)
except Exception:
pass
def on_delta(self, text: str) -> None:
"""Thread-safe callback — called from the agent's worker thread.
When *text* is ``None``, signals a tool boundary: the current message
is finalized and subsequent text will be sent as a new message so it
appears below any tool-progress messages the gateway sent in between.
"""
if text:
self._queue.put(text)
elif text is None:
self.on_segment_break()
def finish(self, final_text: Optional[str] = None) -> None:
"""Signal that the stream is complete.
``final_text``, when provided, is the AUTHORITATIVE completed
``final_response`` — including post-stream augmentation the
accumulator never saw (file-mutation verifier footer,
turn-completion explainer, plugin transforms). The drain loop
adopts it as the finalize payload so the sealed/edited message IS
the true final and no separate corrective send is needed
(live finding #11). Callers that cannot know the final yet
(interrupt/error paths) call ``finish()`` bare — legacy behavior.
"""
if final_text is not None:
self._queue.put((_FINAL_TEXT, final_text))
self._queue.put(_DONE)
# ── Think-block filtering ────────────────────────────────────────
# Models like MiniMax emit inline <think>...</think> blocks in their
# content. The CLI's _stream_delta suppresses these via a state
# machine; we do the same here so gateway users never see raw
# reasoning tags. The agent also strips them from the final
# response (run_agent.py _strip_think_blocks), but the stream
# consumer sends intermediate edits before that stripping happens.
def _filter_and_accumulate(self, text: str) -> None:
"""Add a text delta to the accumulated buffer, suppressing think blocks.
Uses a state machine that tracks whether we are inside a
reasoning/thinking block. Text inside such blocks is silently
discarded. Partial tags at buffer boundaries are held back in
``_think_buffer`` until enough characters arrive to decide.
"""
buf = self._think_buffer + text
self._think_buffer = ""
while buf:
# Case-insensitive matching: models emit mixed-case tag
# variants (<Think>, <THINKING>, …). Match against a
# lowercased view of the buffer with lowercased tag names so
# every case variant is caught with a single canonical form.
lower_buf = buf.lower()
if self._in_think_block:
# Look for the earliest closing tag
best_idx = -1
best_len = 0
for tag in self._CLOSE_THINK_TAGS:
idx = lower_buf.find(tag.lower())
if idx != -1 and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_len = len(tag)
if best_len:
# Found closing tag — discard block, process remainder
self._in_think_block = False
buf = buf[best_idx + best_len:]
else:
# No closing tag yet — hold tail that could be a
# partial closing tag prefix, discard the rest.
max_tag = max(len(t) for t in self._CLOSE_THINK_TAGS)
self._think_buffer = buf[-max_tag:] if len(buf) > max_tag else buf
return
else:
# Look for earliest opening tag at a block boundary
# (start of text / preceded by newline + optional whitespace).
# This prevents false positives when models *mention* tags
# in prose (e.g. "the <think> tag is used for…").
best_idx = -1
best_len = 0
for tag in self._OPEN_THINK_TAGS:
tag_lower = tag.lower()
search_start = 0
while True:
idx = lower_buf.find(tag_lower, search_start)
if idx == -1:
break
# Block-boundary check (mirrors cli.py logic)
if idx == 0:
is_boundary = (
not self._accumulated
or self._accumulated.endswith("\n")
)
else:
preceding = buf[:idx]
last_nl = preceding.rfind("\n")
if last_nl == -1:
is_boundary = (
(not self._accumulated
or self._accumulated.endswith("\n"))
and preceding.strip() == ""
)
else:
is_boundary = preceding[last_nl + 1:].strip() == ""
if is_boundary and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_len = len(tag)
break # first boundary hit for this tag is enough
search_start = idx + 1
if best_len:
# Emit text before the tag, enter think block
self._append_accumulated(buf[:best_idx])
self._in_think_block = True
buf = buf[best_idx + best_len:]
else:
# No opening tag — check for a partial tag at the tail
held_back = 0
for tag in self._OPEN_THINK_TAGS:
tag_lower = tag.lower()
for i in range(1, len(tag)):
if lower_buf.endswith(tag_lower[:i]) and i > held_back:
held_back = i
if held_back:
self._append_accumulated(buf[:-held_back])
self._think_buffer = buf[-held_back:]
else:
# No (partial) open tag — but the model may have
# emitted an orphan close tag like </think> on its
# own (e.g. when a thinking-mode toggle drops the
# matched open, or when upstream stripping is
# incomplete). Strip those before accumulating so
# they never reach the user.
self._append_accumulated(self._strip_orphan_close_tags(buf))
return
@classmethod
def _strip_orphan_close_tags(cls, text: str) -> str:
"""Remove any close tags from *text* that have no matching open.
Mirrors ``agent/think_scrubber.py::StreamingThinkScrubber.
_strip_orphan_close_tags`` so the progressive-display filter
behaves the same as the post-stream final-response scrubber.
An orphan close tag is always noise — stripped along with any
trailing whitespace so surrounding prose flows naturally.
"""
if "</" not in text:
return text
text_lower = text.lower()
out: list[str] = []
i = 0
while i < len(text):
matched = False
if text_lower[i:i + 2] == "</":
for tag in cls._CLOSE_THINK_TAGS:
tag_lower = tag.lower()
tag_len = len(tag_lower)
if text_lower[i:i + tag_len] == tag_lower:
j = i + tag_len
while j < len(text) and text[j] in " \t\n\r":
j += 1
i = j
matched = True
break
if not matched:
out.append(text[i])
i += 1
return "".join(out)
def _flush_think_buffer(self) -> None:
"""Flush any held-back partial-tag buffer into accumulated text.
Called when the stream ends (got_done) so that partial text that
was held back waiting for a possible opening tag is not lost.
"""
if self._think_buffer and not self._in_think_block:
# Strip any orphan close tags that may have been held back —
# see _filter_and_accumulate for context.
self._append_accumulated(self._strip_orphan_close_tags(self._think_buffer))
self._think_buffer = ""
async def run(self) -> None:
"""Async task that drains the queue and edits the platform message."""
# Platform message length limit — leave room for cursor + formatting.
# Use the adapter's length function (e.g. utf16_len for Telegram) so
# overflow detection matches what the platform actually enforces.
# Both resolve PER-CHAT (max_message_length_for_chat): a relay adapter
# fronting N platforms has different caps per chat (Discord 2000 vs
# Telegram 4096); native adapters return their scalar unchanged.
# Gate on isinstance(BasePlatformAdapter) so test MagicMocks (whose
# auto-attributes return mock objects, not callables) fall back to len.
_len_fn: "Callable[[str], int]" = (
self.adapter.message_len_fn_for_chat(self.chat_id)
if isinstance(self.adapter, _BasePlatformAdapter)
else len
)
# Rich-capable adapters (Telegram rich messages) raise this above the
# legacy per-message limit so a reply that fits one rich send/draft
# isn't fragmented at 4096 while streaming. See _raw_message_limit.
_raw_limit = self._raw_message_limit()
_safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100)
# Resolve transport once per run. Native streaming wins over draft
# because the only adapters that declare it (WeCom) cannot edit
# messages at all — there is no edit path to fall back to mid-turn.
# When native is selected we send an empty seed frame immediately so
# the user sees the platform's "typing" indicator before the LLM
# produces any tokens; if that seed fails (no req_id, transport
# error) we disable native and let the consumer take the regular
# edit path (which will in turn refuse and fall back to fallback
# send via the gateway, since SUPPORTS_MESSAGE_EDITING=False).
self._use_native_streaming = self._resolve_native_streaming()
if self._use_native_streaming:
logger.debug(
"Stream consumer using native-stream transport (chat=%s)",
self.chat_id,
)
try:
seed_ok = await self.adapter.send_stream_frame(
"",
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
if seed_ok:
# Mark stream as opened so fallback knows to finalize
self._native_stream_opened = True
except Exception:
logger.debug(
"Native streaming seed frame raised; disabling native",
exc_info=True,
)
seed_ok = False
if not seed_ok:
self._use_native_streaming = False
# Resolve native draft streaming (Telegram drafts) only when native
# streaming is not in use — they target the same first-frame slot.
if self._use_native_streaming:
self._use_draft_streaming = False
else:
self._use_draft_streaming = self._resolve_draft_streaming()
if self._use_draft_streaming:
type(self)._draft_id_counter += 1
self._draft_id = type(self)._draft_id_counter
logger.debug(
"Stream consumer using native-draft transport (chat=%s draft_id=%s)",
self.chat_id, self._draft_id,
)
try:
while True:
# Abandon the stream early if the session has been reset
# (e.g. /new or /stop). Prevents stale deltas from being
# delivered after the user has already moved on.
if not self._run_still_current():
await self._abandon_native_stream()
return
# Drain all available items from the queue
got_done = False
got_segment_break = False
got_flush = False
flush_event = None
got_approval_boundary = False
got_reopen_seed = False
approval_boundary_future = None
approval_boundary_cancelled = None
commentary_text = None
while True:
try:
item = self._queue.get_nowait()
if item is _DONE:
got_done = True
break
if item is _NEW_SEGMENT:
got_segment_break = True
break
if isinstance(item, tuple) and len(item) == 2 and item[0] is _FINAL_TEXT:
# Authoritative turn-final payload (see finish()).
# Adopt it as the finalize content so the seal /
# final edit carries the TRUE final — including
# post-stream augmentation (verifier footer,
# completion explainer) the accumulator never saw.
# Only when this consumer actually streamed
# something this turn: a no-stream turn keeps the
# gateway's normal final-send path (adopting here
# would move delivery ownership for every
# non-streaming model). Skip on a multi-message
# split delivery: heads are already sealed on
# screen, so adopting the full final would repeat
# them inside the tail (#78541 shape).
_streamed_something = bool(
self._accumulated
or self._message_id
or self._last_sent_text
)
if _streamed_something and not self._turn_split_delivery:
_final_payload = self._clean_for_display(item[1])
_visible = self._clean_for_display(self._accumulated)
if _final_payload and _final_payload != _visible:
self._accumulated = item[1]
self._stream_ledger = item[1]
elif _streamed_something and self._turn_split_delivery:
# Split delivery + authoritative final (review
# r2, finding 3): wholesale adoption would
# repeat sealed heads inside the tail (#78541),
# but REFUSING entirely re-creates the #11
# duplicate one level up — a post-split footer
# never enters the ledger, delivered_final_
# matches reports a mismatch, and the gateway
# resends the ENTIRE body+footer. When the
# authoritative final strictly prefix-extends
# the split ledger, the missing suffix is the
# only undelivered content: append it to the
# live tail and the ledger, so the finalize
# carries it and the recorded payload
# reconciles. Non-prefix rewrites keep the
# full-resend fallback (can't patch a rewrite).
_final_raw = item[1]
_ledger = self._stream_ledger
if (
_ledger
and _final_raw.startswith(_ledger)
and len(_final_raw) > len(_ledger)
):
_suffix = _final_raw[len(_ledger):]
self._accumulated += _suffix
self._stream_ledger = _final_raw
continue
if item is _REOPEN_SEED:
got_reopen_seed = True
break
if isinstance(item, tuple) and len(item) == 3 and item[0] is _APPROVAL_BOUNDARY:
got_approval_boundary = True
approval_boundary_future = item[1]
approval_boundary_cancelled = item[2]
break
if isinstance(item, tuple) and len(item) == 2 and item[0] is _COMMENTARY:
commentary_text = item[1]
break
if isinstance(item, tuple) and len(item) == 2 and item[0] is _FLUSH:
# Flush barrier: finalize the current segment like a
# tool boundary, then signal the waiting thread once
# delivery for this iteration has completed (below).
got_flush = True
got_segment_break = True
flush_event = item[1]
break
if isinstance(item, tuple) and len(item) == 2 and item[0] is _TOOL_PROGRESS:
# Tool-progress overlay: accumulate the status line.
# Only effective in native-streaming mode (callers
# gate before enqueue via accepts_tool_progress).
if self._use_native_streaming:
self._tool_progress_lines.append(item[1])
self._tool_progress_active = True
continue # continue draining to batch simultaneous progress lines
self._filter_and_accumulate(item)
except queue.Empty:
break
# Handle approval boundary: close current stream, reset for new turn.
# Must happen before got_done/segment_break processing since it
# produces its own finalize and resets state.
if got_approval_boundary:
await self._handle_approval_boundary(
approval_boundary_future, approval_boundary_cancelled
)
continue
# Handle eager re-seed: the user just answered a clarify prompt.
# Open a fresh native stream NOW (empty seed frame) so the WeCom
# typing bubble reappears immediately, without waiting for the
# LLM's first post-answer delta. Only meaningful in the
# reopen-pending state with native still live and no stream open;
# request_reopen_seed() already gates on that, and we re-check
# here because state may have advanced between put and dequeue.
#
# TRADE-OFF: this moves the start of WeCom's ~6-minute stream
# session limit (STREAM_EXPIRED_ERRCODE 846608, counted from the
# FIRST frame, not renewed by intermediate frames) forward from
# the first post-answer delta to the user-reply instant — the
# effective window shrinks by however long the LLM takes to
# produce its first token. A first token >5min is very rare, and
# if the stream does expire send_stream_frame returns False and
# the else branch below degrades to send(), so the answer still
# lands (only the streaming animation is lost). Acceptable.
if got_reopen_seed:
if (
self._use_native_streaming
and self._awaiting_reopen_after_boundary
and not self._native_stream_opened
):
try:
seed_ok = await self.adapter.send_stream_frame(
"",
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
except Exception as e:
logger.debug(
"Eager reopen seed raised, disabling native: %s", e,
)
seed_ok = False
if seed_ok:
self._native_stream_opened = True
self._native_last_pushed_len = 0
self._awaiting_reopen_after_boundary = False
self._reopen_seeded_eagerly = True
logger.info(
"[latency] Eager re-seed after clarify answer "
"(typing bubble reopened immediately, turn=%s)",
self._turn_id,
)
else:
# Seed failed — degrade to a single buffered send()
# so the post-answer content still lands as one
# bubble (not per-tick fragments on a non-editable
# platform). Mirrors the approval-boundary degrade.
self._use_native_streaming = False
self._native_stream_opened = False
self._native_last_pushed_len = 0
self.cfg.buffer_only = True
continue
# Flush any held-back partial-tag buffer on stream end
# so trailing text that was waiting for a potential open
# tag is not lost.
if got_done:
self._flush_think_buffer()
# Intentional-silence suppression. When the agent chose
# not to reply it emits a bare control marker (NO_REPLY /
# [SILENT] / …). The gateway's whole-response filter
# (gateway/run.py) suppresses this on the non-streaming
# path, but by the time it runs the stream consumer has
# already edited the raw marker onto the screen. Detect
# the exact-marker final buffer here and retract any
# preview instead of finalizing it, so the marker never
# reaches the chat. Substantive prose that merely mentions
# a marker is NOT suppressed (see is_intentional_silence_response).
if _is_intentional_silence_response(
self._clean_for_display(self._accumulated)
):
await self._suppress_silence_marker()
return
# Decide whether to flush an edit
now = time.monotonic()
elapsed = now - self._last_edit_time
should_edit = (
got_done
or got_segment_break
or commentary_text is not None
)
if not self.cfg.buffer_only:
if self._use_native_streaming:
# Fire-and-forget: native streaming has no platform
# edit-rate limit — push every accumulated delta
# immediately. The only gate is "have we accumulated
# anything new at all".
should_edit = should_edit or bool(self._accumulated) or self._tool_progress_active
else:
should_edit = should_edit or (
(elapsed >= self._current_edit_interval
and self._accumulated)
# buffer_threshold is intentionally codepoint-based:
# it's a debounce heuristic ("send updates roughly
# every N visible characters"), not a platform-limit
# check. _len_fn is reserved for overflow detection.
or len(self._accumulated) >= self.cfg.buffer_threshold
)
current_update_visible = False
# Whether the got_done update below was delivered as a FRESH
# persistent send through the native-draft transport (drafts
# have no message id, so the finalize tick is a brand-new
# send that already carried finalize=True). Distinguishes
# that case from an EDIT issued while draft streaming is
# active, which must keep the legacy explicit-finalize pass
# for REQUIRES_EDIT_FINALIZE adapters.
draft_final_fresh_send = False
# Hold back mid-stream edits while the buffer so far could
# still resolve to an intentional-silence marker. Without
# this, a partial marker (e.g. "NO_REPLY" streamed as
# "NO"→"NO_REPLY") would flash onto the screen on an interval
# tick before got_done can suppress it. Only defers display —
# got_done above always resolves the buffer (suppress if it's
# an exact marker, otherwise fall through and flush normally),
# so genuine prose that merely starts marker-like is never lost.
if (
should_edit
and not got_done
and not got_segment_break
and commentary_text is None
and _is_partial_silence_marker(
self._clean_for_display(self._accumulated)
)
):
should_edit = False
if should_edit and (self._accumulated or (self._use_native_streaming and self._tool_progress_active)):
# Split overflow: if accumulated text exceeds the platform
# limit, split into properly sized chunks.
# Native streaming bypasses this entirely — the adapter's
# send_stream_frame handles byte-level truncation against
# the stream protocol's larger limit (e.g. WeCom's 20480
# bytes vs. MAX_MESSAGE_LENGTH's 4000 codepoints).
if (
not self._use_native_streaming
and _len_fn(self._accumulated) > _safe_limit
and self._message_id is None
):
# No existing message to edit (first message or after a
# segment break). Seal only the overflowing head chunks
# as fixed messages, then keep the trailing chunk in
# _accumulated so the normal send/edit path below makes
# it the active preview. That lets chunk 2, 3, ... keep
# updating in-place as later streamed deltas arrive
# instead of posting every split as an immutable message.
chunks = self._truncate_for_stream(
self._accumulated, _safe_limit, _len_fn,
)
if len(chunks) <= 1:
# A malformed/legacy adapter result must not leave
# this overflow branch with an unsplittable payload.
chunks = self._split_text_chunks(
self._accumulated, _safe_limit, _len_fn,
)
chunks_delivered = False
reply_to = self._initial_reply_to_id
all_heads_delivered = len(chunks) > 1
for chunk in chunks[:-1]:
new_id = await self._send_new_chunk(
chunk,
reply_to,
final=got_done,
)
if new_id is None or new_id == reply_to:
# Failed to deliver a sealed head; keep the
# full accumulated text intact so the gateway's
# fallback path can still deliver it completely.
all_heads_delivered = False
chunks_delivered = False
break
chunks_delivered = True
reply_to = new_id
if all_heads_delivered:
self._accumulated = chunks[-1]
# The head chunks are sealed. Clear the edit target
# so the remaining tail is sent as a fresh active
# chunk, then edited by subsequent deltas.
self._message_id = None
self._message_created_ts = None
self._last_sent_text = ""
else:
# A prior head may have landed before a later head
# failed. Do not edit that sealed message with the
# unsplit full payload; let the fallback path retry.
self._message_id = None
self._message_created_ts = None
self._last_sent_text = ""
if chunks_delivered:
# A sealed head is on screen, so this turn is now a
# multi-message delivery. Flag it BEFORE the tail
# send below: the fresh-final route replaces every
# tracked preview with one message, which is only
# valid while the active message holds the whole
# answer. Once heads are sealed it does not, and
# deleting them would drop delivered text (#78541).
self._turn_split_delivery = True
self._last_edit_time = time.monotonic()
if got_done:
tail_delivered = True
if self._accumulated:
tail_delivered = await self._send_or_edit(
self._accumulated, finalize=True,
)
# Only claim final delivery if the sealed chunks and
# final tail actually landed. ``_already_sent`` may
# be True from prior progress/fallback state (#10748).
self._final_response_sent = chunks_delivered and tail_delivered
if self._final_response_sent:
self._final_content_delivered = True
# Multi-message split delivery — record the
# unsplit ledger payload so the gateway can
# still reconcile against final_response
# (#71643, #78541).
self._turn_split_delivery = True
self._record_turn_final_payload(self._accumulated)
return
if got_segment_break:
self._message_id = None
self._fallback_final_send = False
self._fallback_prefix = ""
if not self._accumulated:
continue
# This iteration consumed a _FLUSH barrier and delivered
# the buffered prose via the chunk loop above, then takes
# an early `continue` that skips the bottom-of-loop set.
# Signal here so flush_pending_sync() doesn't stall the
# full timeout waiting on already-delivered content.
if got_flush:
self._signal_flush(flush_event)
continue
# Existing message: edit it with the first chunk, then
# start a new message for the overflow remainder.
while (
_len_fn(self._accumulated) > _safe_limit
and self._message_id is not None
and self._edit_supported
):
_cp_budget = _custom_unit_to_cp(
self._accumulated, _safe_limit, _len_fn,
)
split_at = self._accumulated.rfind("\n", 0, _cp_budget)
if split_at < _cp_budget // 2:
split_at = _cp_budget
chunk = self._accumulated[:split_at]
# finalize=True so the adapter applies platform-specific
# rich-text markup (e.g. Telegram MarkdownV2). This
# sealed chunk will never be edited again — _message_id
# is reset to None right below — so it must receive its
# final formatting pass now, or early split messages
# render raw markdown while only the last chunk renders.
# is_turn_final=False: this is the first of several split
# messages, NOT the turn-final answer, so the fresh-final
# path (opt-in fresh_final_after_seconds) must not mark
# the turn delivered on it (#29346 semantics).
ok = await self._send_or_edit(
chunk, finalize=True, is_turn_final=False,
)
if self._fallback_final_send or not ok:
# Edit failed (or backed off due to flood control)
# while attempting to split an oversized message.
# Keep the full accumulated text intact so the
# fallback final-send path can deliver the remaining
# continuation without dropping content.
break
self._accumulated = self._accumulated[split_at:].lstrip("\n")
self._message_id = None
self._last_sent_text = ""
# Sealed head chunk delivered — this turn is now a
# multi-message delivery (#71643 record semantics).
self._turn_split_delivery = True
display_text = self._accumulated
if not got_done and not got_segment_break and commentary_text is None:
# Native streaming with tool-progress: compose frame
# content that includes tool status overlay. The cursor
# is appended to the composed content for consistency.
if self._use_native_streaming:
display_text = self._compose_frame_content()
if display_text and self.cfg.cursor:
display_text += self.cfg.cursor
else:
display_text += self.cfg.cursor
# Segment break: finalize the current message so platforms
# that need explicit closure (e.g. DingTalk AI Cards) don't
# leave the previous segment stuck in a loading state when
# the next segment (tool progress, next chunk) creates a
# new message below it. got_done has its own finalize
# path below so we don't finalize here for it.
draft_final_fresh_send = (
got_done
and self._use_draft_streaming
and self._message_id is None
)
current_update_visible = await self._send_or_edit(
display_text,
finalize=(got_done or got_segment_break),
# A segment-break finalize closes a preamble, not the
# turn-final answer — only got_done marks delivered (#29346).
is_turn_final=got_done,
)
self._last_edit_time = time.monotonic()
# Reset tool_progress_active flag after frame delivery —
# the lines are still in _tool_progress_lines (for the next
# frame's compose) but we don't need to trigger another
# should_edit until new progress arrives.
if self._tool_progress_active:
self._tool_progress_active = False
if got_done:
if self._accumulated or self._message_id is not None or self._already_sent:
await self._notify_before_finalize()
# Final edit without cursor. If progressive editing failed
# mid-stream, send a single continuation/fallback message
# here instead of letting the base gateway path send the
# full response again.
if (
self._awaiting_reopen_after_boundary
and not self._native_stream_opened
and not self._accumulated
):
# Clarify reopen boundary (LAZY path), but the agent
# produced no post-prompt content. The pre-prompt stream
# was already finalized into a stable bubble at the
# boundary, and no fresh stream was ever re-seeded, so
# there is nothing on screen to close. Do NOT re-seed a
# fresh stream just to emit a lone "✅" placeholder — that
# would leave a meaningless empty bubble below the
# question. Close quietly; the finalized bubble stands.
logger.debug(
"Clarify reopen boundary with no post-prompt content "
"— skipping lone-placeholder finalize (turn=%s)",
self._turn_id,
)
elif (
self._reopen_seeded_eagerly
and self._native_stream_opened
and not self._accumulated
and not current_update_visible
):
# EAGER-seed path: the typing bubble is ALREADY on screen
# (opened the instant the user answered), but the agent
# then produced no content. Unlike the lazy case we
# cannot skip — an open, empty typing bubble would hang
# forever. Close it with an empty finalize (NOT a lone
# "✅", which would be a meaningless bubble below the
# question). Leave the delivery flags as-is: nothing
# substantive was delivered, so the gateway's own
# whole-response filter still governs any fallback.
try:
await self.adapter.send_stream_frame(
"",
finalize=True,
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
except Exception as e:
logger.debug(
"Eager-seed empty finalize failed: %s", e,
)
self._native_stream_opened = False
self._native_last_pushed_len = 0
# Reset for symmetry with _suppress_silence_marker; the
# consumer is per-turn today so this is defensive, but it
# keeps the flag from leaking if a consumer is ever reused
# across turns.
self._reopen_seeded_eagerly = False
logger.debug(
"Eager reopen seed but no post-answer content — "
"closed empty typing bubble (turn=%s)",
self._turn_id,
)
elif self._use_native_streaming:
# Native streaming MUST always close the stream with
# finish=true — even when _accumulated is empty (e.g.
# tool-only turns with no text output). Mirror OpenClaw's
# finishThinkingStream: use a placeholder if needed.
if not current_update_visible:
close_text = self._accumulated or "✅"
self._final_response_sent = await self._send_or_edit(
close_text, finalize=True,
)
if self._final_response_sent:
self._final_content_delivered = True
else:
self._final_response_sent = True
self._final_content_delivered = True
elif self._accumulated:
if self._fallback_final_send:
await self._send_fallback_final(self._accumulated)
elif self._final_response_sent:
# A finalize=True tick above already delivered the
# final answer via the adapter's fresh-final path
# (_try_fresh_final sent a fresh rich message and
# deleted the preview). Running a second finalize
# edit here would duplicate the message / re-delete,
# so just record delivery and stop.
self._final_content_delivered = True
self._record_turn_final_payload(self._accumulated)
elif (
current_update_visible
and (
not self._adapter_requires_finalize
or self._last_edit_overflowed
or draft_final_fresh_send
)
):
# The update above already delivered the final
# accumulated content. Native drafts have no
# message id, so their got_done update is a fresh,
# persistent send with finalize=True; running the
# adapter's explicit finalize hook immediately
# afterward would edit that already-final message
# a second time. This is especially harmful for
# Telegram, where a successful sendRichMessage was
# being followed by editMessageText and could fall
# back to the legacy table-to-bullets formatter.
#
# Also skip the redundant final edit for adapters
# that don't need an explicit finalize signal, and
# for any adapter when the update split-and-
# delivered across continuations: that update
# carried finalize=True itself, and re-finalizing
# with the full text would overflow-split again into
# the adopted continuation, duplicating chunks.
#
# Delivery is recorded via the shared helper so
# the recorded payload is the last ACKED edit,
# not the accumulated text (frozen-preview
# incident class; see _mark_skip_redundant_finalize).
self._mark_skip_redundant_finalize()
elif self._message_id:
# Either the mid-stream edit didn't run (no
# visible update this tick) OR the adapter needs
# explicit finalize=True to close the stream.
self._final_response_sent = await self._send_or_edit(
self._accumulated, finalize=True,
)
if self._final_response_sent:
self._final_content_delivered = True
self._record_turn_final_payload(self._accumulated)
elif self._fallback_final_send:
# The final edit attempt itself may be the one
# that exhausts flood-control strikes and
# promotes the consumer into fallback mode. Do
# not return to the gateway with a full-response
# fallback still pending; send only the unsent
# tail here so the normal gateway send path does
# not duplicate the visible prefix.
await self._send_fallback_final(self._accumulated)
elif not self._already_sent:
# Turn-final retry after the finalize tick above
# failed (transport error, seal exception).
# finalize=True so a stream-is-the-message adapter
# can never route this through the draft-frame
# branch: its no-op dedupe compares against the
# last UNSEALED frame and would report success
# without any transport call, recording a final
# the user never received (silent-loss class).
self._final_response_sent = await self._send_or_edit(
self._accumulated, finalize=True,
)
if self._final_response_sent:
self._final_content_delivered = True
self._record_turn_final_payload(self._accumulated)
return
if commentary_text is not None:
# Stream-is-the-message adapters: commentary posts as its
# own message (no notify → no seal-interception), and the
# native stream continues cumulatively. Resetting here
# would break the append-only invariant the connector's
# delta computation depends on (whole-snapshot re-append).
_stream_is_msg_c = self._stream_is_message()
if _stream_is_msg_c and self._use_draft_streaming:
await self._send_commentary(commentary_text)
self._last_edit_time = time.monotonic()
elif self._use_native_streaming:
# Native streaming (WeCom): commentary is sent as an
# independent message via adapter.send(), but we must
# NOT reset _accumulated — the native stream is
# cumulative and a reset would lose all pre-commentary
# text. Subsequent frames must still carry the full
# accumulated content. Same rationale as segment-break
# no-op for native streaming.
await self._send_commentary(commentary_text)
self._last_edit_time = time.monotonic()
else:
self._reset_segment_state()
await self._send_commentary(commentary_text)
self._last_edit_time = time.monotonic()
self._reset_segment_state()
# Tool boundary: for edit-based platforms, reset message state
# so the next text chunk creates a fresh message below tool-progress.
# For WeCom native streaming: NO reset — stream uses cumulative text,
# so resetting would lose pre-boundary content in subsequent frames.
#
# Exception: when _message_id is "__no_edit__" the platform
# never returned a real message ID (e.g. Signal, webhook with
# github_comment delivery). Resetting to None would re-enter
# the "first send" path on every tool boundary and post one
# platform message per tool call — that is what caused 155
# comments under a single PR. Instead, preserve the sentinel
# so the full continuation is delivered once via
# _send_fallback_final.
# (When editing fails mid-stream due to flood control the id is
# a real string like "msg_1", not "__no_edit__", so that case
# still resets and creates a fresh segment as intended.)
if got_segment_break:
# Stream-is-the-message adapters keep one cumulative native
# stream for the whole turn. Clearing _accumulated here makes
# the next frame a non-prefix snapshot, so the connector's
# append fallback repeats the entire answer at every tool
# boundary. Preserve all stream state; only non-native draft
# and edit-based transports start a new segment.
# ``is True`` + _use_draft_streaming: MagicMock adapters
# return truthy auto-attributes, and an edit-based run on a
# stream-capable adapter still needs the legacy reset.
# WeCom native streaming also uses cumulative text — each
# frame must carry the full content so far, so a segment
# break must NOT reset accumulated state or subsequent
# frames lose the pre-boundary text.
if (
self._stream_is_message()
and self._use_draft_streaming
) or self._use_native_streaming:
pass
else:
# If the segment-break edit failed to deliver the
# accumulated content (flood control that has not yet
# promoted to fallback mode, or fallback mode itself),
# _accumulated still holds pre-boundary text the user
# never saw. Flush that tail as a continuation message
# before the reset below wipes _accumulated — otherwise
# text generated before the tool boundary is silently
# dropped (issue #8124).
if (
self._accumulated
and not current_update_visible
and self._message_id
and self._message_id != "__no_edit__"
):
await self._flush_segment_tail_on_edit_failure()
self._reset_segment_state(preserve_no_edit=True)
# Flush barrier satisfied: the buffered segment (if any) has now
# been finalized and delivered above, so wake the thread blocked
# in flush_pending_sync(). Done last so the waiter only unblocks
# once everything queued before the barrier is on screen.
if got_flush:
self._signal_flush(flush_event)
await asyncio.sleep(0.05) # Small yield to not busy-loop
except asyncio.CancelledError:
# Best-effort final edit on cancellation. finalize=True so
# REQUIRES_EDIT_FINALIZE platforms (Telegram) apply final
# formatting — a plain edit here would leave the entire reply
# rendered as a raw streaming preview while the success flags
# below suppress the gateway's formatted re-send.
# is_turn_final=False keeps _try_fresh_final from setting
# _final_response_sent itself; this handler owns the flags.
_best_effort_ok = False
if self._accumulated and self._message_id:
try:
_best_effort_ok = bool(
await self._send_or_edit(
self._accumulated, finalize=True, is_turn_final=False,
)
)
except Exception:
pass
elif self._message_id is None:
# Native draft path deliberately keeps _message_id=None, so
# the best-effort edit above never runs for it — the stream
# stayed visibly live (streaming indicator) forever and the
# adapter kept armed interception state for the next turn
# to inherit (review B8). Seal in place with what's already
# on screen; sets no delivery flags.
await self._abandon_native_stream()
# Only confirm final delivery if the best-effort send above
# actually succeeded OR if the final response was already
# confirmed before we were cancelled. Previously this
# promoted any partial send (already_sent=True) to
# final_response_sent — which suppressed the gateway's
# fallback send even when only intermediate text (e.g.
# "Let me search…") had been delivered, not the real answer.
if _best_effort_ok and not self._final_response_sent:
self._final_response_sent = True
self._final_content_delivered = True
self._record_turn_final_payload(self._accumulated)
except Exception as e:
logger.error("Stream consumer error: %s", e)
finally:
# Safety net: if run() exits (normal return, cancellation, or
# exception) while a _FLUSH barrier is still queued or was consumed
# but not yet signaled, wake any waiters now. Without this a caller
# blocked in flush_pending_sync() would stall the full timeout when
# the consumer dies mid-flush. Bounded either way, but this makes
# the common case instant instead of timeout-delayed.
try:
while True:
item = self._queue.get_nowait()
if (
isinstance(item, tuple)
and len(item) == 2
and item[0] is _FLUSH
):
self._signal_flush(item[1])
except queue.Empty:
pass
except Exception:
pass
# Strip MEDIA:<path> tags before display. Uses the shared anchored
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
# path ends in a deliverable extension are removed, so an unknown-extension
# path stays visible instead of being silently dropped (issue #34517).
# Streaming and non-streaming paths share the same regex, so a tag is
# treated identically whichever path delivered the text.
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
@staticmethod
def _clean_for_display(text: str) -> str:
"""Strip MEDIA: directives and internal markers from text before display.
The streaming path delivers raw text chunks that may include
``MEDIA:<path>`` tags and ``[[audio_as_voice]]`` directives meant for
the platform adapter's post-processing. The actual media files are
delivered separately via ``_deliver_media_from_response()`` after the
stream finishes — we just need to hide the raw directives from the
user.
"""
return _BasePlatformAdapter.strip_media_directives_for_display(text)
async def _send_new_chunk(
self,
text: str,
reply_to_id: Optional[str],
*,
final: bool = False,
) -> Optional[str]:
"""Send a new message chunk, optionally threaded to a previous message.
Returns the message_id so callers can thread subsequent chunks.
"""
text = self._clean_for_display(text)
if not text.strip():
return reply_to_id
try:
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
reply_to=reply_to_id,
metadata=self._metadata_for_send(
final=final,
expect_edits=not final,
),
)
if result.success and result.message_id:
self._message_id = str(result.message_id)
self._track_preview_ids_from_result(result)
self._already_sent = True
self._last_sent_text = text
# Fresh content bubble — close off any stale tool bubble
# above so the next tool starts a new bubble below.
self._notify_new_message()
return str(result.message_id)
else:
self._edit_supported = False
return reply_to_id
except Exception as e:
logger.error("Stream send chunk error: %s", e)
return reply_to_id
def _visible_prefix(self) -> str:
"""Return the visible text already shown in the streamed message."""
prefix = self._last_sent_text or ""
if self.cfg.cursor and prefix.endswith(self.cfg.cursor):
prefix = prefix[:-len(self.cfg.cursor)]
return self._clean_for_display(prefix)
def _continuation_text(self, final_text: str) -> str:
"""Return only the part of final_text the user has not already seen."""
prefix = self._fallback_prefix or self._visible_prefix()
if prefix and final_text.startswith(prefix):
return final_text[len(prefix):].lstrip()
return final_text
@staticmethod
def _balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
"""Close orphaned ``` fences at each chunk boundary and reopen on the next.
Thin delegate to the shared fence-chunker core in
:mod:`gateway.platforms.helpers` (``balance_fences_across_chunks``);
kept as a method for the existing call sites and tests.
"""
from gateway.platforms.helpers import balance_fences_across_chunks
return balance_fences_across_chunks(chunks)
@staticmethod
def _split_text_chunks(
text: str,
limit: int,
len_fn: "Callable[[str], int]" = len,
) -> list[str]:
"""Split text into reasonably sized chunks for fallback sends.
Chunks are fence-balanced: a split inside a ``` code block closes the
fence on the head chunk and reopens it on the tail, so no chunk leaves
the rest of a message rendering as one giant code block.
Delegates to the shared fence-chunker core
(:func:`gateway.platforms.helpers.split_text_fence_aware`) with this
consumer's knobs: newline-preferred splitting + fence balancing.
"""
from gateway.platforms.helpers import split_text_fence_aware
return split_text_fence_aware(
text,
limit,
len_fn,
prefer_paragraphs=False,
balance_fences=True,
)
def _truncate_for_stream(
self,
text: str,
limit: int,
len_fn: "Callable[[str], int]",
) -> list[str]:
"""Use the adapter's canonical splitter for streaming overflow.
Platform adapters may add word-boundary, code-fence, table, or
platform-specific formatting rules. The consumer must not replace
those rules with newline-only slicing. Non-base test doubles and
legacy adapters retain the historical two-argument call shape.
"""
truncate = getattr(self.adapter, "truncate_message", None)
if not callable(truncate):
return self._split_text_chunks(text, limit, len_fn)
if isinstance(self.adapter, _BasePlatformAdapter):
chunks = truncate(text, limit, len_fn=len_fn)
else:
chunks = truncate(text, limit)
if not isinstance(chunks, (list, tuple)) or not all(
isinstance(chunk, str) for chunk in chunks
):
return self._split_text_chunks(text, limit, len_fn)
return list(chunks)
async def _send_fallback_final(self, text: str) -> None:
"""Send the final continuation after streaming edits stop working.
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():
# Some platforms treat a successful streaming preview as durable
# delivery. Telegram clients can instead lose or retain only part
# of that preview after a failed final edit, so opt-in adapters
# commit the completed answer with a fresh final send.
if (
final_text.strip()
and final_text == self._visible_prefix()
and getattr(
self.adapter,
"RESEND_FINAL_ON_EMPTY_STREAM_FALLBACK",
False,
) is True
):
delivery = await self._send_empty_fallback_final(final_text)
if delivery == "delivered":
return
self._already_sent = True
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
if delivery in {"ambiguous", "preview"}:
# A timeout may mean Telegram accepted the send but the
# client never received the response. A flood rejection
# leaves the complete, ACKed preview as the authoritative
# delivery. Preserve duplicate suppression in both cases.
self._final_content_delivered = True
if delivery == "preview":
# This branch is only reached when the ACKed preview
# already shows the complete final text
# (final_text == _visible_prefix()), so record it as
# the turn-final payload: the gateway's reconciliation
# then confirms delivery instead of re-sending a
# second bubble next to the never-deleted preview
# (#71047 Problem B).
self._record_turn_final_payload(final_text)
else:
self._delivery_ambiguous = True
else:
# A confirmed failure leaves the gateway free to perform
# its normal final send.
self._final_response_sent = False
self._final_content_delivered = False
return
# Nothing new to send — the visible partial already matches final text.
# BUT: if final_text itself has meaningful content (e.g. a timeout
# message after a long tool call), the prefix-based continuation
# calculation may wrongly conclude "already shown" because the
# streamed prefix was from a *previous* segment (before the tool
# boundary). In that case, send the full final_text as-is (#10807).
if final_text.strip() and final_text != self._visible_prefix():
continuation = final_text
else:
# Defence-in-depth for #7183: the last edit may still show the
# cursor character because fallback mode was entered after an
# edit failure left it stuck. Try one final edit to strip it
# so the message doesn't freeze with a visible ▉. Best-effort
# — if this edit also fails (flood control still active),
# _try_strip_cursor has already been called on fallback entry
# and the adaptive-backoff retries will have had their shot.
if (
self._message_id
and self._last_sent_text
and self.cfg.cursor
and self._last_sent_text.endswith(self.cfg.cursor)
):
clean_text = self._last_sent_text[:-len(self.cfg.cursor)]
try:
result = await self._edit_message(
message_id=self._message_id,
content=clean_text,
)
if result.success:
self._last_sent_text = clean_text
except Exception:
pass
self._already_sent = True
self._final_response_sent = True
self._final_content_delivered = True
# The visible partial equals the complete final text (#71643).
# Route through the recorder so a split turn records the full
# ledger rather than this tail-only payload — an unrecorded or
# tail-only split now reads as a mismatch and would re-send
# text the user already has (#78541).
self._record_turn_final_payload(final_text)
return
raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
_len_fn: "Callable[[str], int]" = (
self.adapter.message_len_fn
if isinstance(self.adapter, _BasePlatformAdapter)
else len
)
# Per-chat resolution (relay adapter fronting N platforms): the cap and
# length unit follow the chat's underlying platform, not the adapter
# scalar. Native adapters return their scalar/property unchanged.
if isinstance(self.adapter, _BasePlatformAdapter):
try:
raw_limit = self.adapter.max_message_length_for_chat(self.chat_id)
_len_fn = self.adapter.message_len_fn_for_chat(self.chat_id)
except Exception as e:
logger.debug("per-chat limit resolution failed: %s", e)
safe_limit = max(500, raw_limit - 100)
chunks = self._split_text_chunks(continuation, safe_limit, len_fn=_len_fn)
stale_message_id = self._message_id # partial message to clean up
last_message_id: Optional[str] = None
last_successful_chunk = ""
sent_any_chunk = False
for chunk in chunks:
# Try sending with one retry on flood-control errors.
result = None
for attempt in range(2):
result = await self.adapter.send(
chat_id=self.chat_id,
content=chunk,
metadata=self._metadata_for_send(final=True),
)
if result.success:
break
retry_delay = self._fallback_flood_retry_delay(result)
if attempt == 0 and retry_delay is not None:
logger.debug(
"Flood control on fallback send, retrying in %.1fs",
retry_delay,
)
await asyncio.sleep(retry_delay)
else:
break # non-flood error, long flood wait, or second failure
if not result or not result.success:
if sent_any_chunk:
# Some continuation text already reached the user, but not
# the full response. Do NOT set _final_response_sent — the
# base gateway final-send path should still deliver the
# complete response so the user gets the full answer.
# Suppress only _already_sent to avoid a duplicate send
# of the same partial content.
self._already_sent = True
self._message_id = last_message_id
self._last_sent_text = last_successful_chunk
self._fallback_prefix = ""
return
# No fallback chunk reached the user — allow the normal gateway
# final-send path to try one more time.
self._already_sent = False
self._message_id = None
self._last_sent_text = ""
self._fallback_prefix = ""
return
sent_any_chunk = True
last_successful_chunk = chunk
last_message_id = result.message_id or last_message_id
# Each fallback chunk is a fresh platform message — notify
# so any stale tool-progress bubble gets closed off.
self._notify_new_message()
# Remove the frozen partial message so the user only sees the
# complete fallback response. ONLY safe when the fallback re-sent
# the FULL final text (continuation == final_text). When the
# prefix-based dedup above sent only the missing TAIL, the partial
# message IS the head of the answer — deleting it leaves the user
# with only the last part of the response (the "Gemini sent only
# the second half" symptom). Best-effort — if the platform doesn't
# implement ``delete_message``, the delete fails (flood control still
# active, bot lacks permission, message too old to delete), the
# partial remains but at least the full answer was delivered.
if (
stale_message_id
and stale_message_id != last_message_id
and not self._fallback_preserve_partial_messages
and continuation == final_text
):
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
try:
await delete_fn(self.chat_id, stale_message_id)
except Exception as e:
logger.debug(
"Fallback partial cleanup failed (%s): %s",
stale_message_id, e,
)
self._message_id = last_message_id
self._already_sent = True
self._final_response_sent = True
self._final_content_delivered = True
# The fallback delivered the complete ``final_text`` (as one message
# or prefix + continuation chunks that union to it), so record it as
# the turn-final payload for the gateway's reconciliation (#71643).
# On a split turn ``final_text`` is only the tail — the recorder
# substitutes the unsplit ledger so the sealed heads count as
# delivered too (#78541).
self._record_turn_final_payload(final_text)
self._last_sent_text = chunks[-1]
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
async def _send_empty_fallback_final(self, final_text: str) -> str:
"""Commit a completed answer after Telegram finalization fails.
Returns ``delivered`` on confirmed success, ``failed`` when the
gateway can safely retry, ``ambiguous`` when a timeout may have
reached the platform already, and ``preview`` when flood control
leaves the complete streamed preview as the authoritative delivery.
"""
# Tool/segment boundaries intentionally preserve the run-wide preview
# IDs for normal fresh-final cleanup. This recovery replaces only the
# active final segment, so never delete an earlier finalized preamble.
stale_ids = set(self._segment_preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(str(self._message_id))
result = None
for attempt in range(2):
try:
result = await self.adapter.send(
chat_id=self.chat_id,
content=final_text,
reply_to=self._initial_reply_to_id,
metadata=self._metadata_for_send(final=True),
)
except Exception as exc:
logger.debug("Empty fallback final send failed: %s", exc)
return (
"ambiguous"
if self._send_failure_may_have_delivered(exc)
else "failed"
)
if getattr(result, "success", False):
break
retry_delay = self._fallback_flood_retry_delay(result)
if attempt == 0 and retry_delay is not None:
logger.debug(
"Flood control on empty fallback final send; retrying in %.1fs",
retry_delay,
)
await asyncio.sleep(retry_delay)
continue
if self._is_flood_error(result):
return "preview"
return (
"ambiguous"
if self._send_failure_may_have_delivered(result)
else "failed"
)
new_message_id = getattr(result, "message_id", None)
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == new_message_id:
continue
try:
deleted = await delete_fn(self.chat_id, stale_id)
if deleted is False:
# Telegram's delete_message reports failure by
# returning False, not raising. The same flood
# window that broke the finalize edit can reject
# this delete too, leaving the preview bubble next
# to the fresh final (#71047 Problem B). One short
# bounded retry clears the common transient case;
# a second failure stays best-effort.
await asyncio.sleep(1.0)
await delete_fn(self.chat_id, stale_id)
except Exception as exc:
logger.debug(
"Empty fallback preview cleanup failed (%s): %s",
stale_id,
exc,
)
self._segment_preview_message_ids = set()
self._message_id = new_message_id or "__no_edit__"
self._already_sent = True
self._final_response_sent = True
self._final_content_delivered = True
# Fresh commit of the complete answer after a failed finalize (#71643).
#
# Record ``final_text`` VERBATIM -- do not route through
# _record_turn_final_payload here. This recovery deleted the sealed
# segment previews just above, so the only thing left on screen is the
# message we just sent. On a split turn the ledger holds the sealed
# heads too, and recording it would claim delivery for text this path
# just removed -- the gateway would then suppress and the user would be
# left with a fraction of the answer (the #78541 swallow, reintroduced).
self._delivered_final_text = ensure_closed_code_fences(
self._clean_for_display(final_text or "")
).strip()
self._last_sent_text = final_text
self._fallback_prefix = ""
self._fallback_preserve_partial_messages = False
self._notify_new_message()
return "delivered"
@staticmethod
def _send_failure_may_have_delivered(result_or_exc: Any) -> bool:
"""Return True for timeout failures where retrying may duplicate."""
if getattr(result_or_exc, "retryable", None) is True:
return False
error = str(getattr(result_or_exc, "error", None) or result_or_exc).lower()
name = result_or_exc.__class__.__name__.lower()
return "timeout" in error or "timed out" in error or "timeout" in name
def _fallback_flood_retry_delay(self, result: Any) -> float | None:
"""Return a bounded retry delay for a fallback send, if safe to retry."""
if not self._is_flood_error(result):
return None
try:
delay = float(getattr(result, "retry_after", None) or 3.0)
except (TypeError, ValueError):
delay = 3.0
if delay > self._max_fallback_flood_retry_seconds:
logger.debug(
"Flood control requests %.1fs; leaving final delivery to the gateway",
delay,
)
return None
return max(0.0, delay)
def _is_flood_error(self, result) -> bool:
"""Check if a SendResult failure is due to flood control / rate limiting."""
err = getattr(result, "error", "") or ""
err_lower = err.lower()
return "flood" in err_lower or "retry after" in err_lower or "rate" in err_lower
def _resolve_draft_streaming(self) -> bool:
"""Decide whether this run should use native draft streaming.
Honors ``cfg.transport``:
* ``"edit"`` → never use drafts (legacy progressive-edit path).
* ``"draft"`` → require draft support; gracefully fall back to edit
when the adapter declines. Logs the downgrade at debug.
* ``"auto"`` → use drafts when the adapter supports them for this
chat type; otherwise edit.
Adapter eligibility is checked via
:meth:`BasePlatformAdapter.supports_draft_streaming`, which considers
the chat type (e.g. Telegram drafts are DM-only) and platform-version
gates (e.g. python-telegram-bot 22.6+).
"""
transport = (self.cfg.transport or "edit").lower()
if transport == "edit":
return False
# "off" is filtered upstream by the gateway; treat as edit defensively.
if transport == "off":
return False
# Test adapters are MagicMocks that don't subclass BasePlatformAdapter;
# default them to edit so existing test behaviour is preserved.
if not isinstance(self.adapter, _BasePlatformAdapter):
return False
try:
try:
# Per-chat capability (review r2, finding 2): multi-platform
# relay adapters resolve draft support through the CHAT's
# negotiated descriptor, not the primary identity's. Older
# adapters without the kwarg keep the legacy probe.
supported = self.adapter.supports_draft_streaming(
chat_type=self.cfg.chat_type or None,
metadata=self.metadata,
chat_id=self.chat_id,
)
except TypeError:
supported = self.adapter.supports_draft_streaming(
chat_type=self.cfg.chat_type or None,
metadata=self.metadata,
)
except Exception:
logger.debug("supports_draft_streaming probe raised", exc_info=True)
supported = False
if not supported:
if transport == "draft":
logger.debug(
"Draft streaming requested but unsupported (chat=%s, type=%r) — "
"falling back to edit",
self.chat_id, self.cfg.chat_type,
)
return False
return True
def _resolve_native_streaming(self) -> bool:
"""Decide whether this run should use the native-streaming transport.
Native streaming (e.g. WeCom's ``msgtype: "stream"``) routes ALL
frames — first send, mid-stream updates, and the final ``finish=true``
— through ``adapter.send_stream_frame()``. It takes precedence over
both edit and draft transports because it provides the best client
experience on platforms whose API is built for it (the WeCom client,
for example, renders cumulative content updates in-place with a
built-in typing animation while the stream stays open).
Adapter eligibility:
1. Must subclass :class:`BasePlatformAdapter` (MagicMock test
adapters fall back to edit).
2. Must declare ``SUPPORTS_NATIVE_STREAMING = True`` at the class
level.
3. Must provide ``supports_native_streaming(chat_type, metadata)``
returning truthy for this chat.
"""
if not isinstance(self.adapter, _BasePlatformAdapter):
return False
if not getattr(type(self.adapter), "SUPPORTS_NATIVE_STREAMING", False):
return False
probe = getattr(self.adapter, "supports_native_streaming", None)
if probe is None:
return False
try:
supported = probe(
chat_type=self.cfg.chat_type or None,
metadata=self.metadata,
)
except Exception:
logger.debug(
"supports_native_streaming probe raised", exc_info=True,
)
return False
return bool(supported)
async def _send_draft_frame(self, text: str) -> bool:
"""Emit a single animated draft frame for the current accumulated text.
Returns True when the frame landed. On any failure, permanently
disables drafts for the remainder of this run so subsequent frames
flow through the edit-based path (which can adapt with flood-control
backoff, etc.). Drafts have no message_id and clear naturally on
the client when the response finalizes via a regular sendMessage.
"""
if self._draft_id is None:
# Defensive: should never happen — _use_draft_streaming gate is
# set in tandem with _draft_id in run(). Disable to be safe.
self._use_draft_streaming = False
return False
# Carry the per-turn identity on EVERY frame (review B2): the
# turn-final send goes out via _metadata_for_send, which stamps
# reply_to_message_id — the relay adapter keys draft/seal state on
# that identity, so frames must carry the same one or the final
# cannot find the open stream (flat DMs have no thread metadata
# at all and would otherwise key on the bare chat).
_md = dict(self.metadata) if self.metadata else {}
if self._initial_reply_to_id:
_md.setdefault("reply_to_message_id", self._initial_reply_to_id)
try:
result = await self.adapter.send_draft(
chat_id=self.chat_id,
draft_id=self._draft_id,
content=text,
metadata=_md or None,
)
except Exception as e:
logger.debug(
"send_draft raised, disabling draft transport for this run: %s", e,
)
self._draft_failures += 1
self._use_draft_streaming = False
return False
if not getattr(result, "success", False):
logger.debug(
"send_draft returned success=False, disabling draft transport: %s",
getattr(result, "error", "unknown"),
)
self._draft_failures += 1
self._use_draft_streaming = False
return False
# Frame delivered. Track text for parity with edit-based no-op skip.
self._last_sent_text = text
return True
async def _abandon_native_stream(self) -> None:
"""Close an orphaned native draft stream on turn death (review B8).
Stale-generation exits and cancellations previously returned with
the stream still open: the platform message kept its live
streaming indicator forever, and the adapter's armed interception
state survived into the next turn. Seal in place with the last
delivered frame (adds nothing new on screen), via the adapter's
best-effort ``abandon_open_draft``. Never sets delivery flags —
an abandoned turn's text was partial, and the gateway's normal
paths still own whatever happens next.
"""
if not self._use_draft_streaming:
return
abandon = getattr(type(self.adapter), "abandon_open_draft", None)
if abandon is None:
return
try:
_md = dict(self.metadata) if self.metadata else {}
if self._initial_reply_to_id:
_md.setdefault("reply_to_message_id", self._initial_reply_to_id)
await self.adapter.abandon_open_draft(
self.chat_id,
self._last_sent_text or self._clean_for_display(self._accumulated),
metadata=_md or None,
)
except Exception as e:
logger.debug("abandon_open_draft failed (best-effort): %s", e)
async def _flush_segment_tail_on_edit_failure(self) -> None:
"""Deliver un-sent tail content before a segment-break reset.
When an edit fails (flood control, transport error) and a tool
boundary arrives before the next retry, ``_accumulated`` holds text
that was generated but never shown to the user. Without this flush,
the segment reset would discard that tail and leave a frozen cursor
in the partial message.
Sends the tail that sits after the last successfully-delivered
prefix as a new message, and best-effort strips the stuck cursor
from the previous partial message.
"""
if not self._fallback_final_send:
await self._try_strip_cursor()
visible = self._fallback_prefix or self._visible_prefix()
tail = self._accumulated
if visible and tail.startswith(visible):
tail = tail[len(visible):].lstrip()
tail = self._clean_for_display(tail)
if not tail.strip():
return
try:
# Interim declaration: this tail is pre-boundary text, not the
# turn-final — never let it seal a native stream (see
# _send_commentary).
_md = dict(self.metadata) if self.metadata else {}
_md["_interim_send"] = True
result = await self.adapter.send(
chat_id=self.chat_id,
content=tail,
metadata=_md,
)
if result.success:
self._already_sent = True
except Exception as e:
logger.error("Segment-break tail flush error: %s", e)
async def _try_strip_cursor(self) -> None:
"""Best-effort edit to remove the cursor from the last visible message.
Called when entering fallback mode so the user doesn't see a stuck
cursor (▉) in the partial message.
"""
if not self._message_id or self._message_id == "__no_edit__":
return
prefix = self._visible_prefix()
if not prefix or not prefix.strip():
return
try:
result = await self._edit_message(
message_id=self._message_id,
content=prefix,
)
if getattr(result, "success", False):
self._last_sent_text = prefix
except Exception:
pass # best-effort — don't let this block the fallback path
async def _send_commentary(self, text: str) -> bool:
"""Send a completed interim assistant commentary message."""
text = self._clean_for_display(text)
if not text.strip():
return False
try:
# Declare interim intent: this send is NOT the turn-final. A
# stream-is-the-message adapter (relay Slack native streaming)
# must not let its seal-interception convert this into
# draft(final=true) — that would seal the live stream with
# interim text and orphan the true final into a plain-send
# duplicate (live finding, 2026-08-16 canary).
_md = self._metadata_for_send(final=False) or {}
_md["_interim_send"] = True
# Only pass reply_to for platforms that use reply-anchoring for
# threading. Discord/Telegram use native thread_id in metadata;
# passing reply_to on every commentary creates reply spam.
_plat = getattr(getattr(self.adapter, "platform", None), "value", None)
_platform_name = str(_plat or getattr(self.adapter, "name", "")).lower()
_needs_reply_anchor = _platform_name in ("buzz", "slack", "mattermost", "feishu")
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
reply_to=self._initial_reply_to_id if _needs_reply_anchor else None,
metadata=_md,
)
# Note: do NOT set _already_sent = True here.
# Commentary messages are interim status updates (e.g. "Using browser
# tool..."), not the final response. Setting already_sent would cause
# the final response to be incorrectly suppressed when there are
# multiple tool calls. See: https://github.com/NousResearch/hermes-agent/issues/10454
if result.success:
# Commentary counts as fresh content — close off any
# stale tool bubble above it so the next tool starts a
# new bubble below.
self._notify_new_message()
# Record the exact delivered text so run.py can confirm whether
# an interim "preview" actually carried the final response, vs.
# unrelated commentary delivered during a session split (#14238).
self._delivered_commentary_texts.append(text)
return result.success
except Exception as e:
logger.error("Commentary send error: %s", e)
return False
def _should_send_fresh_final(self) -> bool:
"""Return True when a long-lived preview should be replaced with a
fresh final message instead of an edit.
Conditions:
- Fresh-final is enabled (``fresh_final_after_seconds > 0``).
- We have a real preview message id (not the ``__no_edit__`` sentinel
and not ``None``).
- The preview has been visible for at least the configured threshold.
Ported from openclaw/openclaw#72038.
"""
threshold = getattr(self.cfg, "fresh_final_after_seconds", 0.0) or 0.0
if threshold <= 0:
return False
if not self._message_id or self._message_id == "__no_edit__":
return False
if self._message_created_ts is None:
return False
age = time.monotonic() - self._message_created_ts
return age >= threshold
def _raw_message_limit(self) -> int:
"""Per-message length budget (in the adapter's ``message_len_fn`` units)
before the consumer splits an overflowing reply.
Resolved PER-CHAT via ``max_message_length_for_chat`` — a relay adapter
fronting N platforms has a different cap per chat (Discord 2000 vs
Telegram 4096 vs Slack 39000); native adapters return their scalar
``MAX_MESSAGE_LENGTH`` unchanged. Adapters with a richer send/draft
path (e.g. Telegram rich messages) can raise this above the base via
``streaming_overflow_limit`` so a reply that fits one rich message isn't
fragmented at the legacy edit limit. Falls back to
``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else.
"""
base = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
# isinstance gate: MagicMock adapters return mock objects (truthy, not
# ints) for arbitrary attribute access — keep them on the base limit.
if isinstance(self.adapter, _BasePlatformAdapter):
try:
base = self.adapter.max_message_length_for_chat(self.chat_id)
except Exception as e:
logger.debug("max_message_length_for_chat failed: %s", e)
try:
cap = self.adapter.streaming_overflow_limit()
except Exception as e:
logger.debug("streaming_overflow_limit check failed: %s", e)
cap = None
if isinstance(cap, int) and cap > base:
return cap
return base
def _track_preview_id(self, message_id: Optional[str]) -> None:
"""Record a real preview message id for finalization cleanup."""
if message_id and message_id != "__no_edit__":
message_id = str(message_id)
self._preview_message_ids.add(message_id)
self._segment_preview_message_ids.add(message_id)
def _track_preview_ids_from_result(self, result: Any) -> None:
"""Record every message id a send/edit result exposes: the primary id
plus any continuation ids from an oversized split
(``continuation_message_ids`` or ``raw_response['message_ids']``)."""
self._track_preview_id(getattr(result, "message_id", None))
for mid in (getattr(result, "continuation_message_ids", None) or ()):
self._track_preview_id(mid)
raw = getattr(result, "raw_response", None) or {}
if isinstance(raw, dict):
for mid in (raw.get("message_ids") or ()):
self._track_preview_id(mid)
def _adapter_prefers_fresh_final(self, text: str) -> bool:
"""Return True when the adapter would rather finalize a streamed reply
by sending a fresh message and deleting the preview than by editing the
preview in place — e.g. Telegram, whose ``sendRichMessage`` send path
currently renders richer markdown than Hermes' MarkdownV2 edit path.
Returns False when there is no real preview to replace (no message id,
or the ``__no_edit__`` sentinel), when the adapter doesn't expose the
hook, or on any error (the consumer then keeps the edit-in-place path).
"""
if not self._message_id or self._message_id == "__no_edit__":
return False
fn = getattr(self.adapter, "prefers_fresh_final_streaming", None)
if fn is None:
return False
try:
try:
# Pass the chat id so multi-platform adapters (relay) resolve
# the decision through THIS chat's negotiated platform, not
# the primary identity's. Without it a Slack-primary relay
# with unfurl force-on misroutes a fronted Telegram/Discord
# chat's final through the fresh-send lane (duplicate
# delivery: those descriptors advertise no ``delete`` op),
# and the mirror posture leaves fronted Slack chats dark.
result = fn(text, metadata=self.metadata, chat_id=self.chat_id)
except TypeError:
try:
# Single-platform hook signature (Telegram, base class):
# (content, metadata=None) — no chat_id keyword.
result = fn(text, metadata=self.metadata)
except TypeError:
# Adapter / test double whose hook doesn't accept the
# metadata keyword — fall back to the positional-only form.
result = fn(text)
except Exception as e:
logger.debug("prefers_fresh_final_streaming check failed: %s", e)
return False
# ``is True`` (not ``bool(...)``) so a MagicMock adapter's auto-child
# method — truthy by default in tests — does not wrongly enable the
# fresh-final path. Mirrors the REQUIRES_EDIT_FINALIZE gate in __init__.
return result is True
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
"""Send ``text`` as a brand-new message (best-effort delete the old
preview) so the platform's visible timestamp reflects completion
time. Returns True on successful delivery, False on any failure so
the caller falls back to the normal edit path.
``is_turn_final`` is False when finalizing an interim segment at a tool
boundary (a preamble) rather than the turn-final answer; the
final-delivery flag is then left unset so the gateway still delivers the
real answer from the next API call (#29346).
Ported from openclaw/openclaw#72038.
"""
# Every preview message the user has seen for this response: the
# current one plus any continuation fragments tracked while streaming
# (an oversized reply split across the platform's edit limit). All of
# them are replaced by the single fresh message below.
#
# That replacement is only sound while ``text`` holds the whole answer.
# On a multi-message split the head chunks were sealed and dropped out
# of ``_accumulated``, so ``text`` is just the tail — deleting the
# sealed heads would erase text the user already received and leave the
# complete reply nowhere on screen (#78541). Keep the sealed messages
# and take the normal edit path instead.
if self._turn_split_delivery:
return False
stale_ids = set(self._preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(self._message_id)
try:
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
metadata=self._metadata_for_send(final=True),
)
except Exception as e:
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
return False
if not getattr(result, "success", False):
return False
# Adopt the new message id as the current message so subsequent
# callers (e.g. overflow split loops, finalize retries) see a
# consistent state.
new_message_id = getattr(result, "message_id", None)
# Successful fresh send — try to delete the stale preview(s) so the
# user doesn't see the old edit-stuck message(s) underneath. Cleanup
# is best-effort; platforms that don't implement ``delete_message``
# just leave the preview behind (still an acceptable outcome — the
# visible final timestamp is the important part). Never delete the
# message we just sent.
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == "__no_edit__" or stale_id == new_message_id:
continue
try:
await delete_fn(self.chat_id, stale_id)
except Exception as e:
logger.debug(
"Fresh-final preview cleanup failed (%s): %s",
stale_id, e,
)
self._preview_message_ids = set()
if new_message_id:
self._message_id = new_message_id
self._message_created_ts = time.monotonic()
else:
# Send succeeded but platform didn't return an id — treat the
# delivery as final-only and fall back to "__no_edit__" so we
# don't try to edit something we can't address.
self._message_id = "__no_edit__"
self._message_created_ts = None
self._already_sent = True
self._last_sent_text = text
if is_turn_final:
self._final_response_sent = True
# Fresh send carried exactly ``text`` — record it so the gateway
# can reconcile the flag against the completed response
# (#71643/#95382 content-vs-flag contract).
self._record_turn_final_payload(text)
return True
async def _suppress_silence_marker(self) -> None:
"""Retract any streamed preview when the final reply is a silence marker.
The agent chose not to respond and emitted a bare control marker. Any
preview message the consumer already put on screen (a partial marker
flushed on an interval tick, or a preamble before a tool boundary) must
be removed so the raw marker is never left visible. Deletion reuses the
same best-effort ``delete_message`` path as :meth:`_try_fresh_final`.
Crucially, the delivery flags (``_final_response_sent`` /
``_final_content_delivered``) are left **False**: nothing was delivered.
The gateway then does not mistake the marker for a delivered reply, and
its own whole-response filter turns the marker into "" so no fallback
send happens either. ``_already_sent`` is likewise cleared so the
gateway's ``already_sent`` short-circuits do not fire.
"""
# Native-stream bubbles (e.g. WeCom) are NOT deletable messages — they
# are an open stream closed by a finalize frame, not delete_message.
# If a stream is open (notably one opened by an EAGER re-seed after a
# clarify answer, where the typing bubble is already on screen with no
# content), close it with an empty finalize so it doesn't hang forever.
# Do this before the delete loop; keep the delivery flags False below.
if self._native_stream_opened:
try:
await self.adapter.send_stream_frame(
"",
finalize=True,
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
except Exception as e:
logger.debug(
"Silence-marker native stream close failed: %s", e,
)
self._native_stream_opened = False
self._native_last_pushed_len = 0
self._reopen_seeded_eagerly = False
stale_ids = set(self._preview_message_ids)
if self._message_id and self._message_id != "__no_edit__":
stale_ids.add(self._message_id)
delete_fn = getattr(self.adapter, "delete_message", None)
if delete_fn is not None:
for stale_id in stale_ids:
if not stale_id or stale_id == "__no_edit__":
continue
try:
await delete_fn(self.chat_id, stale_id)
except Exception as e:
logger.debug(
"Silence-marker preview cleanup failed (%s): %s",
stale_id, e,
)
self._preview_message_ids = set()
self._message_id = None
self._accumulated = ""
self._stream_ledger = ""
self._last_sent_text = ""
self._already_sent = False
self._final_response_sent = False
self._final_content_delivered = False
self._delivered_final_text = None
self._delivery_ambiguous = False
self._turn_split_delivery = False
logger.info(
"Suppressed streamed intentional-silence marker (chat=%s)",
self.chat_id,
)
async def _send_or_edit(
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
) -> bool:
"""Send or edit the streaming message.
Returns True if the text was successfully delivered (sent or edited),
False otherwise. Callers like the overflow split loop use this to
decide whether to advance past the delivered chunk.
``finalize`` is True when this is the last edit in a streaming
sequence.
"""
# Strip MEDIA: directives so they don't appear as visible text.
# 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)
# Preserve the pre-fence-closed form for stream-is-the-message draft
# frames: appending a closing ``` to a mid-code-block frame makes
# frame N not a prefix of frame N+1, so the connector's append-only
# delta computation falls back to a whole-snapshot re-append (the
# stacked-copies class). Native streams render unclosed fences
# progressively; the finalize path below still fence-closes the
# real final message.
_pre_fence_text = 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
if self.cfg.cursor:
visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "")
_visible_stripped = visible_without_cursor.strip()
if not _visible_stripped:
# For native streaming: even when the display text is empty (e.g.
# MEDIA-only response cleaned away), we MUST send a finalize frame
# to close the thinking bubble. Use placeholder text.
if finalize and self._use_native_streaming and self._native_stream_opened:
try:
ok = await self.adapter.send_stream_frame(
"✅",
finalize=True,
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
if ok:
self._final_response_sent = True
self._final_content_delivered = True
except Exception as e:
logger.debug("Finalize empty stream failed: %s", e)
return True # cursor-only / whitespace-only update
if not text.strip():
return True # nothing to send is "success"
# Guard: do not create a brand-new standalone message when the only
# visible content is a handful of characters alongside the streaming
# cursor. During rapid tool-calling the model often emits 1-2 tokens
# before switching to tool calls; the resulting "X ▉" message risks
# leaving the cursor permanently visible if the follow-up edit (to
# strip the cursor on segment break) is rate-limited by the platform.
# This was reported on Telegram, Matrix, and other clients where the
# ▉ block character renders as a visible white box ("tofu").
# Existing messages (edits) are unaffected — only first sends gated.
_MIN_NEW_MSG_CHARS = 4
if (self._message_id is None
and self.cfg.cursor
and self.cfg.cursor in text
and len(_visible_stripped) < _MIN_NEW_MSG_CHARS):
return True # too short for a standalone message — accumulate more
# Native streaming transport (e.g. WeCom): every frame — first send,
# mid-stream updates, and the final answer — flows through
# adapter.send_stream_frame(), which manages the underlying stream
# lifecycle (init seed → cumulative updates → finish=true). The
# adapter's send/edit_message paths are NOT touched in this mode.
#
# Throttling: WeCom AI Bot caps replies at ~30 frames/min per chat.
# With 15 concurrent users, we need ≤2 frames per turn on average
# to stay under the limit. 60 chars ≈ one short sentence, which
# produces 3-5 frames per turn — close to OpenClaw's block-level cadence.
if self._use_native_streaming:
# Re-seed if stream was closed (e.g., by approval boundary)
# and we have new content to send.
if not self._native_stream_opened and text:
try:
seed_ok = await self.adapter.send_stream_frame(
"",
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
if seed_ok:
self._native_stream_opened = True
# A fresh stream is open — post-prompt content will
# stream into it, so got_done no longer needs the
# lone-placeholder guard for this turn.
self._awaiting_reopen_after_boundary = False
# INFO (temporary latency probe): this is the moment the
# C bubble / typing animation first becomes visible after
# a clarify answer. Comparing this timestamp to the
# boundary-finalize log below quantifies the "typing is
# slow to reappear" delay the user reported.
logger.info(
"[latency] Re-opened native stream after boundary "
"(turn=%s, waited for first delta)",
self._turn_id,
)
else:
self._use_native_streaming = False
except Exception as e:
logger.debug("Re-seed failed, disabling native streaming: %s", e)
self._use_native_streaming = False
if self._use_native_streaming:
# For WeCom native streaming: segment breaks should NOT finalize
# the stream. WeCom renders each finalize as a separate message bubble.
# Only turn-final (got_done) and approval boundary should close the stream.
# Tool boundary segment breaks just continue accumulating in the same stream.
if finalize and not is_turn_final:
finalize = False
# Fire-and-forget: send immediately when content differs from
# the last pushed frame. No buffering / throttle — WeCom long-
# connection mode has no polling cadence, so every cumulative
# update is pushed as soon as it arrives.
if not finalize and text == self._last_sent_text:
return True # unchanged — skip
# B2 — timeout-inversion race fix. For a finalize frame, mark
# delivery OPTIMISTICALLY, before send_stream_frame blocks on the
# ack. The finalize frame's bytes are written to the wire by an
# independent control-worker task *before* the ack wait begins, and
# for WeCom a frame on the wire is already rendered by the client
# (the same premise the ack-timeout-as-success path already relies
# on). Setting the flag here means a gateway join-cancel during the
# ack wait — the timeout inversion between run.py's stream_task join
# and adapter._REPLY_ACK_TIMEOUT — can no longer strand
# final_content_delivered=False while WeCom has already shown the
# message, which is what produced the duplicate normal send
# (see tests/gateway/test_wecom_double_send.py and
# docs/rca-wecom-stream-final-ack-timeout-duplicate.md).
#
# A DEFINITIVE dispatch failure (ok is False below: stream never
# opened, 846608 expired, errcode 6000, or the call raised) rolls
# the mark back so the edit/send fallback still delivers exactly
# once. Residual window: if the consumer is cancelled between this
# optimistic mark and the control worker actually writing the bytes
# (queue latency, sub-ms in practice), the message could be
# suppressed without being sent — far rarer than the guaranteed
# duplicate this replaces, and the send-path idempotency guard
# cannot help there (nothing was sent). Accepted trade-off.
_optimistic_finalize = bool(finalize)
if _optimistic_finalize:
self._final_response_sent = True
self._final_content_delivered = True
# Record what this finalize frame carries so the gateway's
# content reconciliation (#71643/#95382) can judge the flag:
# a frame holding only a stale/partial snapshot must not
# suppress the corrective send of the complete response.
self._record_turn_final_payload(text)
ok = False
try:
ok = await self.adapter.send_stream_frame(
text,
finalize=finalize,
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
except Exception as e:
logger.debug(
"send_stream_frame raised, disabling native streaming: %s", e,
)
ok = False
if ok:
self._already_sent = True
self._last_sent_text = text
self._native_last_pushed_len = len(text)
if finalize:
self._final_response_sent = True
self._final_content_delivered = True
return True
# Dispatch failed definitively — roll back the optimistic finalize
# mark so the edit/send fallback below delivers the content once.
if _optimistic_finalize:
self._final_response_sent = False
self._final_content_delivered = False
# Roll back the recorded payload too — nothing was delivered.
self._delivered_final_text = None
# Native streaming refused / failed — switch off so this and
# subsequent frames take the edit/send fallback path below.
# The adapter is responsible for marking the chat as expired
# so it doesn't keep retrying the dead stream session.
self._use_native_streaming = False
# If the stream bubble was opened (seed frame succeeded), try
# best-effort finalize to close it before falling back to send().
# This prevents leaving an unclosed thinking stream visible to the
# user. Check _native_stream_opened (not _native_last_pushed_len)
# because the seed frame has zero length but still opens the bubble.
if self._native_stream_opened:
try:
await self.adapter.send_stream_frame(
text,
finalize=True,
chat_id=self.chat_id,
reply_to=self._initial_reply_to_id,
turn_id=self._turn_id,
)
logger.debug("Native fallback: finalized stream (best-effort close)")
# DO NOT mark _final_content_delivered here.
# The finalize frame closes the typing bubble, but WeCom may
# not actually render the content (e.g., errcode 6000 race).
# Let the fallback send() path deliver the content reliably.
except Exception as e:
logger.debug(
"Native fallback: failed to finalize stream: %s", e,
)
# Fall through to the edit/send paths so any accumulated text
# still reaches the user as a one-shot proactive markdown send.
# The final answer is delivered via the regular sendMessage path
# below — drafts have no message_id so we can't finalize them
# in-place; the regular sendMessage clears the draft naturally on
# the client and gives the user a real message in their history.
# Skip when:
# * finalize=True (this is the final answer; needs to be a real message)
# * an edit path is already established (message_id is set, e.g. after
# a tool-boundary segment break where the prior text was finalized
# as a real sendMessage and the next text segment continues editing
# that one — staying on edit-based for that segment is correct).
# Stream-is-the-message exception (finding #5, live canary): for
# adapters like relay Slack native streaming, a segment-break
# finalize must NOT become a real send — the adapter's seal
# interception would convert it to draft(final=true), sealing the
# stream at EVERY tool boundary (one frozen cumulative message per
# segment; only the turn-final seal belongs). Those adapters keep
# ONE stream per turn: mid-turn boundaries just emit another
# cumulative frame; only got_done (is_turn_final) seals.
_stream_is_msg = self._stream_is_message()
if (
self._use_draft_streaming
and self._message_id is None
and (not finalize or (_stream_is_msg and not is_turn_final))
):
# Stream-is-the-message frames must stay prefix-stable: use the
# pre-fence-closed text (see _pre_fence_text above). The turn
# final still goes through the fence-closed path below.
_frame_text = _pre_fence_text if _stream_is_msg else text
# Finding #6 (live canary, the duplicate-content root cause):
# strip the gateway's text cursor from draft frames. Native
# streams render their own typing indicator, and a cursor-
# suffixed frame breaks the connector's prefix-delta check on
# EVERY tick ("...text▉" is never a prefix of "...text more▉"),
# triggering its whole-text fallback append — the user saw each
# cumulative snapshot stacked inside one message, ▉ included.
if self.cfg.cursor and _frame_text.endswith(self.cfg.cursor):
_frame_text = _frame_text[: -len(self.cfg.cursor)]
# No-op skip: identical to the last frame we sent.
if _frame_text == self._last_sent_text:
return True
ok = await self._send_draft_frame(_frame_text)
if ok:
# Drafts mark "we put something on screen" but DO NOT set
# _already_sent — that flag gates the gateway's fallback
# final-send path and we still need that to fire so the
# user gets a real message (drafts have no message_id).
return True
# Failure already disabled drafts for this run; fall through to
# the regular edit/send path below.
self._last_edit_overflowed = False
try:
if self._message_id is not None:
if self._edit_supported:
# Skip if text is identical to what we last sent.
# Exception: adapters that require an explicit finalize
# call (REQUIRES_EDIT_FINALIZE) must still receive the
# finalize=True edit even when content is unchanged, so
# their streaming UI can transition out of the in-
# progress state. Everyone else short-circuits.
if text == self._last_sent_text and not (
finalize and self._adapter_requires_finalize
):
return True
# Fresh-final for long-lived previews: when finalizing
# the last edit in a streaming sequence, if the
# original preview has been visible for at least
# ``fresh_final_after_seconds``, send the completed
# reply as a fresh message so the platform's visible
# timestamp reflects completion time instead of the
# preview creation time. Best-effort cleanup of the
# old preview follows. Ported from
# openclaw/openclaw#72038. Gated by config so the
# legacy edit-in-place path stays the default.
#
# Adapters can also opt in regardless of the time threshold
# via prefers_fresh_final_streaming (e.g. Telegram, whose
# send path renders richer markdown than its edit path):
# finalizing through edit would visibly downgrade a rich
# preview, so re-deliver as a fresh message + delete the
# preview instead.
#
# When the adapter exposes prefers_fresh_final_streaming
# and explicitly returns False, the time-based threshold
# must NOT override that decision. On Telegram the
# fresh-final path sends a Rich Message (sendRichMessage)
# that overlaps with the legacy MarkdownV2 preview already
# visible from streaming — both remain on screen because
# the old message is only best-effort deleted. Adapters
# without the hook still get the time-based fresh-final.
# (#47048)
# Check the *class* for the hook so MagicMock adapters
# (which auto-create attributes on access) are not
# falsely detected as having it. Also check instance
# __dict__ for test doubles that explicitly assign the
# attribute (e.g. adapter.prefers_fresh_final_streaming
# = MagicMock(return_value=False)).
_has_prefers_hook = (
hasattr(type(self.adapter),
"prefers_fresh_final_streaming")
or "prefers_fresh_final_streaming"
in getattr(self.adapter, "__dict__", {})
)
_prefers_fresh = self._adapter_prefers_fresh_final(text)
if (
finalize
and (
_prefers_fresh
or (
not _has_prefers_hook
and self._should_send_fresh_final()
)
)
and await self._try_fresh_final(
text, is_turn_final=is_turn_final,
)
):
return True
# Edit existing message
result = await self._edit_message(
message_id=self._message_id,
content=text,
finalize=finalize,
)
if result.success:
self._already_sent = True
# Record any continuation fragments an oversized edit
# split off, so fresh-final can clean them all up.
self._track_preview_ids_from_result(result)
# Adapter may have split-and-delivered an oversized
# edit across the original message + N continuations.
# When that happens, ``message_id`` is the LAST visible
# continuation and ``_last_sent_text`` no longer reflects
# the on-screen content (the new message only holds the
# final chunk's text), so subsequent edits must target
# the new id and skip-if-same comparisons must reset.
# Fire on_new_message so tool-progress bubbles linearize
# below the new continuation, not the original.
# ``getattr`` with default keeps backwards compat with
# SimpleNamespace mocks in tests that pre-date the field.
_continuation_ids = getattr(result, "continuation_message_ids", ()) or ()
if (
_continuation_ids
and result.message_id
and result.message_id != self._message_id
):
self._last_edit_overflowed = True
# Adapter adopted continuation messages — this
# turn is a multi-message delivery (#71643).
self._turn_split_delivery = True
self._message_id = str(result.message_id)
self._message_created_ts = time.monotonic()
self._last_sent_text = ""
self._notify_new_message()
else:
self._last_sent_text = text
# Successful edit — reset flood strike counter
self._flood_strikes = 0
return True
else:
immediate_final_fallback = False
if (
finalize
and is_turn_final
and self.cfg.cursor
and self._last_sent_text.endswith(self.cfg.cursor)
and self._visible_prefix() == text
):
# The final clean-up edit failed, but the complete
# answer is already visible from the last streaming
# frame (usually with only the cursor still stuck on
# screen). Mark the content delivered so the
# gateway suppresses its normal full final send;
# otherwise users see the same long answer twice
# when Telegram/Discord rate-limit this cosmetic
# final edit (#36965, #25349).
self._final_content_delivered = True
# ``text`` is already cleaned/fence-closed here and
# equals the visible prefix — the on-screen content
# IS this finalize payload (#71643). Record it on
# split turns too: post-#78541 an unrecorded split
# reads as a mismatch and would re-send this
# already-visible answer, reintroducing the
# duplicate #45517 fixed (#36965 / #25349).
self._record_turn_final_payload(text)
raw_response = getattr(result, "raw_response", None)
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
# Telegram edited/sent one or more overflow chunks,
# but not the complete response. Preserve the
# visible prefix so the got_done fallback sends the
# missing tail instead of marking a clipped topic
# reply as final delivery.
self._message_id = str(
raw_response.get("last_message_id")
or result.message_id
or self._message_id
)
delivered_prefix = raw_response.get("delivered_prefix")
if isinstance(delivered_prefix, str) and delivered_prefix:
self._last_sent_text = delivered_prefix
self._fallback_prefix = delivered_prefix
self._fallback_preserve_partial_messages = text.startswith(
delivered_prefix
)
else:
self._fallback_prefix = self._visible_prefix()
self._fallback_preserve_partial_messages = False
self._fallback_final_send = True
self._edit_supported = False
self._already_sent = True
if getattr(result, "continuation_message_ids", ()):
self._notify_new_message()
return False
# Edit failed. If this looks like flood control / rate
# limiting, use adaptive backoff: double the edit interval
# and retry on the next cycle. Only permanently disable
# edits after _MAX_FLOOD_STRIKES consecutive failures.
if self._is_flood_error(result):
self._flood_strikes += 1
self._current_edit_interval = min(
self._current_edit_interval * 2, 10.0,
)
logger.debug(
"Flood control on edit (strike %d/%d), "
"backoff interval → %.1fs",
self._flood_strikes,
self._MAX_FLOOD_STRIKES,
self._current_edit_interval,
)
immediate_final_fallback = (
finalize
and is_turn_final
and getattr(
self.adapter,
"FALLBACK_ON_FINAL_EDIT_FLOOD",
False,
) is True
)
if (
self._flood_strikes < self._MAX_FLOOD_STRIKES
and not immediate_final_fallback
):
# Don't disable edits yet — just slow down.
# Update _last_edit_time so the next edit
# respects the new interval.
self._last_edit_time = time.monotonic()
return False
if immediate_final_fallback:
logger.debug(
"Turn-final edit hit flood control; "
"entering fallback immediately"
)
# Non-flood error OR flood strikes exhausted: enter
# fallback mode — send only the missing tail once the
# final response is available.
logger.debug(
"Edit failed (strikes=%d), entering fallback mode",
self._flood_strikes,
)
self._fallback_prefix = self._visible_prefix()
self._fallback_final_send = True
self._edit_supported = False
self._already_sent = True
# Best-effort: strip the cursor from the last visible
# message so the user doesn't see a stuck ▉. A
# turn-final Telegram flood skips this cosmetic edit:
# another edit would consume the same flood budget and
# delay the fallback send that carries the answer.
if not immediate_final_fallback:
await self._try_strip_cursor()
return False
else:
# Editing not supported — skip intermediate updates.
# The final response will be sent by the fallback path.
return False
else:
# First message — send new, threaded to the original user message
# so it lands in the correct topic/thread.
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
reply_to=self._initial_reply_to_id,
metadata=self._metadata_for_send(
final=finalize,
expect_edits=not finalize,
),
)
if result.success:
if result.message_id:
self._message_id = result.message_id
# Track when the preview first became visible to
# the user so fresh-final logic can detect stale
# preview timestamps on long-running responses.
self._message_created_ts = time.monotonic()
# Record this (and any continuation fragments from an
# oversized first send) for fresh-final cleanup.
self._track_preview_ids_from_result(result)
else:
self._edit_supported = False
self._already_sent = True
self._last_sent_text = text
if not result.message_id:
self._fallback_prefix = self._visible_prefix()
self._fallback_final_send = True
# Sentinel prevents re-entering the first-send path on
# every delta/tool boundary when platforms accept a
# message but do not return an editable message id.
self._message_id = "__no_edit__"
# Notify the gateway that a fresh content bubble was
# created so any accumulated tool-progress bubble above
# gets closed off — the next tool fires into a new
# bubble below, preserving chronological order.
self._notify_new_message()
return True
else:
# Initial send failed — disable streaming for this session
self._edit_supported = False
return False
except Exception as e:
logger.error("Stream send/edit error: %s", e)
return False