"""Gateway streaming consumer — bridges sync agent callbacks to async platform delivery. The agent fires stream_delta_callback(text) synchronously from its worker thread; on_delta() queues it (queue.Queue) and the async run() task buffers, rate-limits, and progressively edits a single platform message (send, then editMessageText — supported everywhere; draft/native transports are optional per adapter). 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, ) import contextlib logger = logging.getLogger("gateway.stream_consumer") # Queue sentinels (see run() for handling). _DONE = object() # stream complete _NEW_SEGMENT = object() # finalize current message, start a fresh one _COMMENTARY = object() # (_COMMENTARY, text): completed interim commentary _TOOL_PROGRESS = object() # (_TOOL_PROGRESS, line): overlay line for the native bubble # (_FINAL_TEXT, text): authoritative completed final_response (incl. post-stream # augmentation such as verifier footers) enqueued just before _DONE, so the # finalize/seal delivers the TRUE final and the recorded payload reconciles. _FINAL_TEXT = object() # (_FLUSH, threading.Event): barrier — drain loop finalizes/delivers anything # buffered, then sets the event. Used by flush_pending_sync() before a blocking # interactive prompt so the prompt lands below buffered prose, not above it. _FLUSH = object() # Interaction boundary (approval OR clarify prompt): finalize the current # stream; post-prompt output goes via send() or a re-opened stream. _APPROVAL_BOUNDARY = object() # EAGER native re-seed right after a clarify answer, before the first post-answer # delta. On WeCom typing is driven by the seed frame (send_typing is a no-op) # and lazy re-seed on first delta measured 48s of dead air. _REOPEN_SEED = object() # Boundary finalize text when nothing has accumulated yet; overridable per # boundary via close_for_approval_prompt(placeholder=...). _DEFAULT_BOUNDARY_PLACEHOLDER = "⏸ 等待审批中..." def escape_code_fences_for_display(text: str) -> str: """Replace each ``` with \\`\\`\\` so text can be wrapped in an outer ``` block. Reasoning content that quotes code would otherwise break the outer fence. """ 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 ``` and/or ` if the text has orphaned code markers. Output truncated mid-code-block (token limit, finish_reason="length") would otherwise render everything after the orphan as one code block / inline span. Trade-off: a spurious close creates a brief empty span at the end, far less harmful than the alternative. Odd ``` count → append a fence on its own line; then, with complete ```…``` regions stripped, odd ` count → append a backtick. """ if not isinstance(text, str) or not text: return text if text.count("```") % 2 == 1: text = text.rstrip("\n") + "\n```" # Strip complete fenced regions (and any trailing unclosed ``` that leaks # through) so their internal backticks don't pollute the standalone count. 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, deliver the final as a fresh message if the preview has been # visible at least this long, so the visible timestamp reflects completion # rather than first token. 0 = always edit in place. Enabled per-platform. fresh_final_after_seconds: float = 0.0 # "auto"/"draft": native draft streaming when adapter+chat support it, else # edit. "edit": progressive editMessageText. "off": handled by the gateway # before the consumer is built. transport: str = "edit" # Originating chat type ("dm", "group", ...); gates 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) agent = AIAgent(..., stream_delta_callback=consumer.on_delta) task = asyncio.create_task(consumer.run()) # ... run agent in thread pool ... consumer.finish() # signal completion await task # wait for final edit """ # Consecutive flood-control failures before progressive edits are disabled # for the rest of the stream. _MAX_FLOOD_STRIKES = 3 # Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and # run_agent.py _strip_think_blocks() tag variants. _OPEN_THINK_TAGS = ( "", "", "", "", "", "", ) _CLOSE_THINK_TAGS = ( "", "", "", "", "", "", ) # Class-wide monotonic counter for draft ids (Telegram animates a draft only # when the same non-zero draft_id is reused within a response). Seeded from # a RANDOM nonce, not 0 or the clock: draft_id is the wire identity for the # relay connector's per-(channel, draft_id) sealed-stream tombstones, which # outlive this (scale-to-zero) process — a replayed id is answered out of # the OLD tombstone and the reply is silently dropped. Epoch-ms seeds still # collide on same-ms starts/forks/clock steps; 49 bits keeps ids + turn # counts 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 # No-arg callback fired whenever a fresh content bubble is created # (first send, commentary, overflow chunk, fallback continuation); the # gateway uses it to open the next tool-progress bubble BELOW resumed # content instead of editing the old one above. Exceptions swallowed. self._on_new_message = on_new_message # Fired once on entering the finalization path so the gateway can pause # typing refreshes before a slow rich-text final edit. self._on_before_finalize = on_before_finalize self._initial_reply_to_id = initial_reply_to_id # Per-turn id passed to adapter.send_stream_frame() so concurrent # consumers (/background, parallel subagents) don't interfere. import uuid self._turn_id = str(uuid.uuid4()) self._queue: queue.Queue = queue.Queue() self._accumulated = "" # Mirror of ``_accumulated`` NOT truncated when overflow splits seal # head chunks; records a reconcilable turn-final payload for splits. self._stream_ledger = "" self._message_id: Optional[str] = None # time.monotonic() when ``_message_id`` was first assigned; fresh-final # logic uses it to detect long-lived previews. self._message_created_ts: Optional[float] = None # Every real preview message id put on screen this response (first send # + continuation messages). Fresh-final deletes them all so a reply # split at the edit limit leaves no stale fragments above the final. self._preview_message_ids: "set[str]" = set() # IDs from only the active text segment: 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 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 sends only the missing tail after a partial # overflow delivery: the visible prefix is content, not a stale preview. self._fallback_preserve_partial_messages = False # Telegram bounds edit retries at 5s; a final-delivery fallback must not # hold the stream task through a longer flood cooldown. 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 # Final content reached the user even if the cosmetic final edit # (cursor removal) then failed. self._final_content_delivered = False # Exact cleaned payload of the turn-final delivery that set the flags # above. The gateway compares it against the completed final_response # before trusting the flags (a successful finalize edit may carry only a # stale preview snapshot). ``None`` = no record → legacy trust. self._delivered_final_text: Optional[str] = None # Answer delivered across multiple sealed messages (overflow split / # continuation adoption). With a recorded payload, delivered_final_matches # can still reconcile; payload-less split delivery must NOT inherit # legacy trust (it swallowed complete replies after partial splits). self._turn_split_delivery = False # A full-final send timed out in a way that MAY have reached the # platform — the only payload-less case that keeps legacy trust, since # re-sending risks a duplicate rather than recovering a loss. self._delivery_ambiguous = False self._delivered_commentary_texts: list[str] = [] # Finalized visible text of each segment, so has_delivered_text still # matches after _reset_segment_state clears _last_sent_text. self._delivered_segment_texts: list[str] = [] # Only platforms needing an explicit finalize call (e.g. DingTalk AI # Cards) force a redundant final edit. ``is True`` keeps MagicMock # adapters in tests from enabling this path. self._adapter_requires_finalize: bool = ( getattr(adapter, "REQUIRES_EDIT_FINALIZE", False) is True ) # Returns False after /new or /stop; run() then abandons the stream # instead of delivering 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 = "" # Draft streaming: resolved at the start of run(). Animated frames go # via adapter.send_draft instead of edits; the final answer still uses # the normal first-send path (drafts have no message_id). self._use_draft_streaming = False self._draft_id: Optional[int] = None # First draft failure permanently disables drafts for this response. self._draft_failures = 0 self._before_finalize_notified = False # Native streaming (e.g. WeCom msgtype "stream"): the ONLY delivery # channel for the turn — seed, cumulative updates, and finish=true all # go through adapter.send_stream_frame(). Resolved at the start of # run(); disabled on any failure so the consumer falls back to edit/send. self._use_native_streaming = False # Seed frame sent (even though it has zero visible content); fallback # logic uses it to decide whether the stream must be finalized first. self._native_stream_opened = False # Visible chars last pushed to the native stream; throttles frames # under WeCom's 30 frames/min ceiling. self._native_last_pushed_len = 0 # Boundary state, set by close_for_approval_prompt(); race-free because # boundaries are processed serially. ``_boundary_reopen``: keep native # streaming enabled so post-prompt output re-opens a fresh stream via # the lazy re-seed (clarify: short waits) instead of degrading to send() # (approval: unbounded waits, stream may go stale). self._boundary_placeholder = _DEFAULT_BOUNDARY_PLACEHOLDER self._boundary_reason = "Approval" self._boundary_reopen = False # Boundary asked to reopen but nothing has re-seeded yet; keeps got_done # from opening a stream just to emit a lone "✅" placeholder. self._awaiting_reopen_after_boundary = False # An EAGER re-seed (_REOPEN_SEED) already opened a fresh bubble before # any content, so got_done must actively finalize it when the agent # produces nothing — otherwise a blank typing bubble hangs forever. self._reopen_seeded_eagerly = False # Tool-progress overlay (native streaming only): lines from # on_tool_progress() shown in the 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 (a multi-platform relay adapter's class attribute can only reflect its primary identity), falling back to the legacy attribute. 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: """True only when native streaming is active (gates in-stream tool progress).""" return self._use_native_streaming def on_tool_progress(self, line: str) -> None: """Thread-safe: overlay a tool-progress line in the native bubble until the next delta.""" if line: self._queue.put((_TOOL_PROGRESS, line)) def _compose_frame_content(self) -> str: """Native frame content: text, with any tool-progress lines below a rule.""" if self._accumulated and self._tool_progress_lines: 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: """Per-send metadata for stream-created messages. ``final`` sets notify=True (Mattermost treats notify-worthy sends as final content when deciding whether a broken thread root may fall back flat). ``expect_edits`` keeps editable previews on Telegram's legacy send path while final sends may use richer 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: """Message ID of the last-sent or edited message.""" return self._message_id @property def final_content_delivered(self) -> bool: """True when the final content reached the user, even if the cosmetic final edit 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.""" # Contract: adapters must accept finalize= even when False (test-guarded). kwargs = { "chat_id": self.chat_id, "message_id": message_id, "content": content, "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 # Real text overwrites the tool-progress overlay. 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. Records what was ACKED on the wire, not ``_accumulated``: a throttled edit stream can reach this state with the last acked edit still holding an earlier (cursor-suffixed) preview, and recording the accumulator would let that frozen preview suppress the corrective send. """ 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 actually saw as this turn's final answer. Normalized like ``_send_or_edit`` output (media-directive strip + fence closing) so the gateway can compare it to the completed final_response. On a multi-message split ``text`` is only the trailing chunk (overflow truncates ``_accumulated`` as head chunks seal), so the un-truncated ``_stream_ledger`` is recorded instead — else the gateway sees a mismatch and re-sends an answer the user already received. """ 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]: """Tri-state reconcile of the recorded turn-final payload against ``final_text``. A *successful* finalize edit can still carry only a stale preview, so call success alone must not confirm delivery. True: recorded payload (or an earlier segment/commentary) matches — suppressing the normal final send is safe. False: recorded payload differs, or payload-less multi-message split (flag alone must not suppress). None: nothing recorded on a non-split legacy/ambiguous path; caller keeps its flag-trusting behavior. """ 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: return False # No recorded payload: judge against the FINAL content rather than # trusting the flag — a consumer whose visible text lacks the # completed response (first-edit prefix, mid-stream truncation) has # demonstrably NOT delivered it. ``_already_sent`` gates the match: # draft frames set ``_last_sent_text`` but are ephemeral and # deliberately don't set ``_already_sent``. if self._already_sent and self.has_delivered_text(final_text): return True # Only a timed-out full-final send that MAY have landed keeps legacy # trust — re-sending risks a duplicate. if self._delivery_ambiguous: return None return False if self._delivered_final_text.strip() == target: return True # A segment break / commentary may have delivered it under another record. return bool(self.has_delivered_text(final_text)) 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: """Queue an interaction boundary (approval / clarify prompt) from sync context. run() processes it serially: finalize the current native stream with accumulated text (``placeholder`` when nothing accumulated yet), then per ``reopen``: False (approval; long unbounded waits, stream may go stale) disables native streaming and batches post-prompt output into one send(); True (clarify) keeps native enabled so post-prompt output re-opens a fresh stream via the lazy re-seed, degrading to send() if that fails. ``reason`` labels log lines ("Approval"/"Clarify"). Returns a (Future, cancelled_flag) tuple; the Future resolves True once processed. cancelled_flag is kept for callers that set it on timeout but the handler no longer reads it (finalize always runs). Without native streaming returns a bare, already-resolved Future. """ loop = None with contextlib.suppress(RuntimeError): loop = asyncio.get_running_loop() if not self._use_native_streaming: f = asyncio.Future() if loop else concurrent.futures.Future() f.set_result(True) return f # Instance attributes are race-free: boundaries are processed one at a time. self._boundary_placeholder = placeholder or _DEFAULT_BOUNDARY_PLACEHOLDER self._boundary_reason = reason or "Approval" self._boundary_reopen = bool(reopen) boundary_future = loop.create_future() if loop else 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 agent worker thread until everything queued so far is delivered. Enqueues a ``(_FLUSH, Event)`` barrier; run() drains earlier items (FIFO), finalizes the current segment, then sets the event. Returns False on timeout so the caller proceeds even if the consumer task is not running. Used before a blocking interactive prompt (clarify poll) so the question doesn't land 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: """Thread-safe: request an EAGER native re-seed after a clarify answer. Posts _REOPEN_SEED so run() sends an empty seed frame before the first post-answer delta (WeCom typing bubble reappears immediately). No-op unless reopen-pending on a native stream with no stream open, so a stray call can't open a spurious bubble mid-stream or on approval. """ 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. Every loop path that consumed a ``_FLUSH`` barrier (including early ``continue`` paths) must call this; a missed set isn't a deadlock (bounded wait) but stalls the caller for the full timeout. """ if flush_event is None: return with contextlib.suppress(Exception): flush_event.set() def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None: if preserve_no_edit and self._message_id == "__no_edit__": return # Retain the segment's visible text so has_delivered_text still matches. 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() self._tool_progress_lines = [] self._tool_progress_active = False # A segment boundary means what we delivered was an interim preamble — # clear the final flags so a premature setter can't fool the gateway. # Safe: got_done returns before any reset; 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 # Telegram-shaped drafts: bump draft_id so the next segment animates as # a fresh preview below the tool-progress bubbles instead of over the # prior finalized draft. Stream-is-the-message adapters (relay Slack) # keep ONE stream per turn — a bump there opened a new platform stream # per tool boundary, leaving one frozen cursor-suffixed message per # segment; the connector's suffix-delta logic appends segments instead. if self._use_draft_streaming and 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: """Serially process an interaction boundary dequeued by run(). Finalize the current stream with accumulated text (stable message for pre-prompt content), then route post-prompt output per ``_boundary_reopen``. The stream is not kept open across approval because the WeCom finalize ack only confirms server receipt: after a long idle gap the client may stop tracking the stream, and a suppressed final send would then leave the user with nothing. """ _reason = self._boundary_reason or "Approval" delivery_failed = False try: if self._native_stream_opened: 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: # Typing bubble may still show partial content; deliver # pre-prompt text via 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: 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: keep native enabled; marking the stream closed makes # the next post-prompt delta re-open a fresh one via the lazy # re-seed in _send_or_edit. Do NOT set buffer_only — post-prompt # output should stream. The gap between this INFO and the # "Re-opened native stream" INFO is the typing-reappear latency. self._native_stream_opened = False self._native_last_pushed_len = 0 self._awaiting_reopen_after_boundary = True self._reset_segment_state() 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: post-approval output goes via send(). buffer_only # delivers it in one shot on got_done, avoiding mid-stream # flushes that create multiple messages on non-editable platforms. self._use_native_streaming = False self._native_stream_opened = False self._native_last_pushed_len = 0 self.cfg.buffer_only = True 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: if boundary_future is not None: try: if ( isinstance(boundary_future, (asyncio.Future, concurrent.futures.Future)) and 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 stream completion. ``final_text`` is the AUTHORITATIVE completed final_response (incl. post-stream augmentation the accumulator never saw); the drain loop adopts it as the finalize payload so no corrective send is needed. Interrupt/error paths that can't know the final call ``finish()`` bare. """ if final_text is not None: self._queue.put((_FINAL_TEXT, final_text)) self._queue.put(_DONE) # ── Think-block filtering ──────────────────────────────────────── # Some models emit inline ... blocks in content. The agent # strips them from the final response, but intermediate edits go out before # that, so mirror the CLI's _stream_delta state machine here. def _filter_and_accumulate(self, text: str) -> None: """Append a delta to the buffer, discarding think blocks. Partial tags at buffer boundaries are held in ``_think_buffer`` until enough characters arrive to decide. """ buf = self._think_buffer + text self._think_buffer = "" while buf: # Case-insensitive: models emit , , … lower_buf = buf.lower() if self._in_think_block: 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: self._in_think_block = False buf = buf[best_idx + best_len:] else: # Hold a tail that could be a partial close tag; 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: # Earliest opening tag at a block boundary (start of text, or # newline + optional whitespace) — prose that merely *mentions* # a tag must not trigger. 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: self._append_accumulated(buf[:best_idx]) self._in_think_block = True buf = buf[best_idx + best_len:] else: # Hold back a partial open 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: # An orphan (thinking-mode toggle dropped the # open, or incomplete upstream stripping) is noise. self._append_accumulated(self._strip_orphan_close_tags(buf)) return @classmethod def _strip_orphan_close_tags(cls, text: str) -> str: """Remove close tags (plus trailing whitespace) that have no matching open. Mirrors ``agent/think_scrubber.py::StreamingThinkScrubber`` so the progressive display matches the post-stream scrubber. """ if " None: """On stream end, flush text held back waiting for a possible open tag.""" if self._think_buffer and not self._in_think_block: 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.""" # Length function and limit resolve PER-CHAT (a relay adapter fronting # N platforms has different caps per chat) using the platform's unit # (e.g. utf16 for Telegram). isinstance gate: MagicMock auto-attributes # aren't callables, so test doubles 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 ) _raw_limit = self._raw_message_limit() _safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100) # Native streaming wins over draft: adapters that declare it (WeCom) # cannot edit at all. Send an empty seed frame now so the user sees # "typing" before the first token; on seed failure fall back to the # edit path (which the gateway's fallback send then handles). 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: 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 # Drafts and native streaming 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: # Session reset (/new, /stop): abandon rather than deliver stale deltas. if not self._run_still_current(): await self._abandon_native_stream() return 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: # Adopt the authoritative final (see finish()) as the # finalize content — only if this consumer streamed # something (a no-stream turn keeps the gateway's # normal final-send ownership). _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: wholesale adoption would repeat # sealed heads inside the tail, but refusing # entirely makes the gateway resend the ENTIRE # body+footer. If the final strictly # prefix-extends the ledger, append only the # missing suffix; non-prefix rewrites keep the # full-resend fallback. _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: # Barrier: finalize like a tool boundary, signal 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: 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 # Boundary produces its own finalize and resets state, so it # must run before got_done/segment_break processing. if got_approval_boundary: await self._handle_approval_boundary( approval_boundary_future, approval_boundary_cancelled ) continue # Eager re-seed after a clarify answer. Re-check the gate # (state may have advanced between put and dequeue). Trade-off: # WeCom's ~6-minute stream limit (errcode 846608, counted from # the FIRST frame) now starts at the reply instant rather than # the first post-answer delta; if it expires, send_stream_frame # returns False and we degrade to send() — only animation is lost. 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: # Degrade to a single buffered send() (one bubble, # not per-tick fragments), like the approval path. self._use_native_streaming = False self._native_stream_opened = False self._native_last_pushed_len = 0 self.cfg.buffer_only = True continue if got_done: self._flush_think_buffer() # A bare intentional-silence marker (NO_REPLY / [SILENT]): # the gateway's whole-response filter runs too late for a # streamed preview, so retract it here instead of finalizing. 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: # No platform edit-rate limit: push every delta immediately. 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 a codepoint debounce heuristic, # not a platform-limit check (_len_fn is for overflow). or len(self._accumulated) >= self.cfg.buffer_threshold ) current_update_visible = False # got_done update went out as a FRESH send via the draft # transport (drafts have no message id) already carrying # finalize=True — unlike an EDIT during draft streaming, which # still needs the explicit finalize pass for # REQUIRES_EDIT_FINALIZE adapters. draft_final_fresh_send = False # Defer mid-stream edits while the buffer could still resolve # to a silence marker ("NO"→"NO_REPLY") so it never flashes on # screen; got_done always resolves the buffer, so marker-like # prose 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)): # Overflow split. Native streaming bypasses this: the # adapter truncates against the stream protocol's own limit. if ( not self._use_native_streaming and _len_fn(self._accumulated) > _safe_limit and self._message_id is None ): # No message to edit yet: seal only the overflowing head # chunks, keep the tail in _accumulated so it becomes the # active preview that later deltas edit in place. chunks = self._truncate_for_stream( self._accumulated, _safe_limit, _len_fn, ) if len(chunks) <= 1: # Malformed/legacy adapter result must still be splittable. 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: # Keep the full text intact for the gateway fallback. all_heads_delivered = False chunks_delivered = False break chunks_delivered = True reply_to = new_id if all_heads_delivered: self._accumulated = chunks[-1] # Heads are sealed (or a later head failed): never edit a # sealed message with the unsplit payload — the tail is # sent fresh, or the fallback path retries. self._message_id = None self._message_created_ts = None self._last_sent_text = "" if chunks_delivered: # Flag BEFORE the tail send: fresh-final replaces # every tracked preview with one message, which is # only valid while the active message holds the whole # answer — deleting sealed heads drops delivered text. 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, ) # ``_already_sent`` may be True from prior # progress/fallback state — only heads + tail count. self._final_response_sent = chunks_delivered and tail_delivered if self._final_response_sent: self._final_content_delivered = True 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 # Early `continue` skips the bottom-of-loop flush signal. if got_flush: self._signal_flush(flush_event) continue # Existing message: seal it with the head, start a new # message for the 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: this sealed chunk is never edited again, # so it needs its rich-text pass now or it renders raw. # is_turn_final=False: a split head is not the answer, so # fresh-final must not mark the turn delivered on it. ok = await self._send_or_edit( chunk, finalize=True, is_turn_final=False, ) if self._fallback_final_send or not ok: # Keep the full text intact for the fallback final send. break self._accumulated = self._accumulated[split_at:].lstrip("\n") self._message_id = None self._last_sent_text = "" self._turn_split_delivery = True display_text = self._accumulated if not got_done and not got_segment_break and commentary_text is None: 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 finalizes so platforms needing explicit # closure (DingTalk AI Cards) don't leave the previous # segment stuck loading; got_done has its own finalize below. 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 answer. is_turn_final=got_done, ) self._last_edit_time = time.monotonic() # Lines stay in _tool_progress_lines for the next compose; # only new progress should trigger another should_edit. 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 one continuation here rather than letting # the gateway resend the full response. if ( self._awaiting_reopen_after_boundary and not self._native_stream_opened and not self._accumulated ): # Lazy reopen, no post-prompt content: nothing is open # on screen, so don't re-seed just to emit a lone "✅". 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, no content: the typing bubble IS on screen # and would hang forever — close it with an empty # finalize (not a lone "✅"). Delivery flags untouched. 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 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 streams MUST close with finish=true even when # empty (tool-only turns) — 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: # Fresh-final already delivered above; a second # finalize would duplicate / re-delete. 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. A # second finalize would re-edit an already-final # message (Telegram: sendRichMessage followed by # editMessageText falls back to the legacy # formatter), or overflow-split again into an # adopted continuation, duplicating chunks. self._mark_skip_redundant_finalize() elif self._message_id: # No visible update this tick, or the adapter needs # explicit finalize=True. 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: # This edit may have exhausted flood strikes and # promoted fallback mode; send only the unsent # tail now so the gateway doesn't duplicate the # visible prefix. await self._send_fallback_final(self._accumulated) elif not self._already_sent: # Retry after the finalize tick failed. finalize=True # keeps stream-is-the-message adapters out of the # draft-frame branch, whose dedupe against the last # UNSEALED frame would report success with no # transport call (silent loss). 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: # Cumulative transports (stream-is-the-message drafts, WeCom # native): commentary posts as its own message and the # stream continues — resetting _accumulated would break the # append-only invariant / lose pre-commentary text. if ( self._stream_is_message() and self._use_draft_streaming ) or self._use_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: edit-based transports reset so the next chunk # is a fresh message below tool progress. Cumulative transports # (stream-is-the-message drafts, WeCom native) must NOT reset — # clearing _accumulated makes the next frame a non-prefix # snapshot and the connector re-appends the whole answer. # preserve_no_edit: "__no_edit__" (platform never returned a # real id — Signal, github_comment webhook) must keep its # sentinel or every tool boundary posts a new message (155 # comments on one PR); the continuation goes out once via # _send_fallback_final. A real id from a flood-failed edit # still resets as intended. if got_segment_break: if ( self._stream_is_message() and self._use_draft_streaming ) or self._use_native_streaming: pass else: # If the segment-break edit didn't land (flood control / # fallback mode), _accumulated holds unseen pre-boundary # text — flush it before the reset wipes it. 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) # Done last so the waiter unblocks only 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. finalize=True so REQUIRES_EDIT_FINALIZE # platforms apply formatting (the flags below suppress the gateway's # formatted re-send); is_turn_final=False because this handler owns # the flags, not _try_fresh_final. _best_effort_ok = False if self._accumulated and self._message_id: with contextlib.suppress(Exception): _best_effort_ok = bool( await self._send_or_edit( self._accumulated, finalize=True, is_turn_final=False, ) ) elif self._message_id is None: # Draft path keeps _message_id=None, so the edit above never # runs for it; seal in place (else the stream stays visibly # live and the adapter keeps armed interception state for the # next turn). Sets no delivery flags. await self._abandon_native_stream() # Only a successful best-effort edit confirms delivery — a partial # send (already_sent) may be just "Let me search…", not the 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: # Wake any still-queued _FLUSH waiters so a consumer dying mid-flush # doesn't stall flush_pending_sync() for its full timeout. 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 # Shared with the non-streaming path so a MEDIA tag is treated identically # either way (only deliverable-extension paths are stripped). _MEDIA_RE = MEDIA_TAG_CLEANUP_RE @staticmethod def _clean_for_display(text: str) -> str: """Hide MEDIA: / [[audio_as_voice]] directives; media is delivered after the stream.""" 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 chunk threaded to ``reply_to_id``; returns the new message_id.""" 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 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 _split_text_chunks( text: str, limit: int, len_fn: "Callable[[str], int]" = len, ) -> list[str]: """Split text for fallback sends: newline-preferred, fence-balanced across chunks.""" 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]: """Split via the adapter's canonical truncate_message (platform-specific rules). Non-base test doubles / legacy adapters keep the 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) # Balance fences BEFORE computing the continuation so the closing # fence reaches the user even when only the tail is delivered. final_text = ensure_closed_code_fences(final_text) continuation = self._continuation_text(final_text) self._fallback_final_send = False if not continuation.strip(): # Telegram clients can lose (part of) a streamed preview after a # failed final edit, so opt-in adapters commit 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"}: # Timeout: Telegram may have accepted the send. Flood # rejection: the complete ACKed preview is authoritative. # Keep duplicate suppression in both cases. self._final_content_delivered = True if delivery == "preview": # Preview already shows the full final (checked above); # record it so the gateway doesn't re-send next to it. self._record_turn_final_payload(final_text) else: self._delivery_ambiguous = True else: # Confirmed failure: gateway performs its normal final send. self._final_response_sent = False self._final_content_delivered = False return # The prefix may be from a *previous* segment (before a tool # boundary), wrongly reading as "already shown" — send final_text as-is. if final_text.strip() and final_text != self._visible_prefix(): continuation = final_text else: # Best-effort strip of a cursor left stuck by the edit failure # that entered fallback mode. 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 # Recorder substitutes the full ledger on a split turn. 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 cap/unit (relay adapter fronting N platforms). 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: 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: # Partial continuation landed: do NOT set _final_response_sent # (gateway must still deliver the full answer); _already_sent # only prevents a duplicate of the partial. self._already_sent = True self._message_id = last_message_id self._last_sent_text = last_successful_chunk self._fallback_prefix = "" return # Nothing landed — let the gateway final send try once more. 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 self._notify_new_message() # Best-effort delete of the frozen partial — ONLY when the FULL final # was re-sent. If only the missing tail went out, the partial IS the # head of the answer ("sent only the second half" symptom). 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 # Recorder substitutes the unsplit ledger on a split turn. 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", "failed" (gateway may retry), "ambiguous" (a timeout may have reached the platform), or "preview" (flood control leaves the complete streamed preview authoritative). """ # Segment-scoped only: 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 reports delete failure by returning False; # the flood window that broke the finalize can reject # this too. One bounded retry, then 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 # Record VERBATIM, not via _record_turn_final_payload: the sealed # previews were just deleted, so the ledger (which still holds sealed # heads) would claim delivery for text this path removed. 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: """Whether this run should use draft streaming per ``cfg.transport``. "edit"/"off" → False. "draft"/"auto" → the adapter's supports_draft_streaming probe (chat type, platform-version gates); "draft" logs the downgrade when unsupported. """ transport = (self.cfg.transport or "edit").lower() if transport in ("edit", "off"): return False # MagicMock test adapters default to edit. if not isinstance(self.adapter, _BasePlatformAdapter): return False try: try: # Per-chat probe (relay adapters resolve through the CHAT's # descriptor); 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: """Whether to use native streaming (adapter.send_stream_frame for ALL frames). Requires a BasePlatformAdapter subclass with class-level SUPPORTS_NATIVE_STREAMING and a truthy supports_native_streaming probe. """ 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 one draft frame; any failure permanently disables drafts for this run. Drafts have no message_id and clear on the client when the final sendMessage lands. """ if self._draft_id is None: # Should never happen (set in tandem with _use_draft_streaming in run()). self._use_draft_streaming = False return False # Every frame must carry the same reply_to_message_id the final send # gets from _metadata_for_send: the relay adapter keys draft/seal state # on it, else the final can't find the open stream (flat DMs have no # thread metadata and would 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 self._last_sent_text = text # parity with the edit-based no-op skip return True async def _abandon_native_stream(self) -> None: """Seal an orphaned draft stream in place on turn death (stale exit / cancel). Otherwise the message keeps its live indicator forever and the adapter's armed interception state leaks into the next turn. Never sets delivery flags — the gateway's normal paths own what 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: """Send the unseen tail after the delivered prefix as a new message before a segment reset. Also best-effort strips the stuck cursor from the 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: must never 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 removing a stuck cursor when entering fallback mode.""" 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: # Interim: a stream-is-the-message adapter's seal-interception must # not turn this into draft(final=true), which would seal the live # stream with interim text and orphan the true final. _md = self._metadata_for_send(final=False) or {} _md["_interim_send"] = True # reply_to only for reply-anchored threading; Discord/Telegram use # thread_id metadata and reply_to on every commentary is 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, ) # Do NOT set _already_sent: commentary is interim, and the flag # would suppress the real final after multiple tool calls. if result.success: self._notify_new_message() # Lets run.py confirm whether an interim send carried the final. 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: """True when fresh-final is enabled and a real preview has been visible ≥ threshold.""" 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-chat length budget (adapter ``message_len_fn`` units) before overflow splits. Rich-capable adapters may raise it via ``streaming_overflow_limit`` so a reply that fits one rich message isn't fragmented at the edit limit. """ base = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096) # isinstance gate keeps MagicMock adapters (mock attrs, not ints) on base. 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 the primary id plus any continuation ids from an oversized split.""" 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: """Adapter's prefers_fresh_final_streaming hook (e.g. Telegram's richer send path). False when there's no real preview, no hook, or on any error. """ 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: # chat_id lets relay adapters decide via THIS chat's platform; # otherwise a Slack-primary relay misroutes fronted chats # through the fresh-send lane (duplicates: no delete op). result = fn(text, metadata=self.metadata, chat_id=self.chat_id) except TypeError: try: result = fn(text, metadata=self.metadata) # single-platform signature except TypeError: result = fn(text) # test doubles without the metadata kwarg except Exception as e: logger.debug("prefers_fresh_final_streaming check failed: %s", e) return False # ``is True`` keeps MagicMock auto-children from enabling fresh-final. return result is True async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool: """Send ``text`` as a fresh message and best-effort delete the preview(s). Returns False on any failure so the caller falls back to edit. ``is_turn_final=False`` (interim segment at a tool boundary) leaves the final-delivery flag unset so the gateway still delivers the real answer. """ # Replacing every tracked preview is only sound while ``text`` holds # the whole answer; after a split, deleting sealed heads would erase # delivered text — take the 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 new_message_id = getattr(result, "message_id", None) # Best-effort preview cleanup; never delete the message 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: # No id returned: sentinel so we never try to edit it. 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 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 bare silence marker. Delivery flags and ``_already_sent`` are left False: nothing was delivered, and the gateway's whole-response filter turns the marker into "" so no fallback send happens either. """ # A native-stream bubble isn't a deletable message — close an open one # (e.g. from an eager re-seed) with an empty finalize so it doesn't hang. 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; True if delivered. ``finalize`` marks the last edit of a streaming sequence. Callers such as the overflow split loop use the result to decide whether to advance. """ text = self._clean_for_display(text) # Stream-is-the-message draft frames must stay prefix-stable: a closing # ``` appended to a mid-code-block frame makes frame N not a prefix of # N+1 and the connector re-appends the whole snapshot. The final # message is still fence-closed below. _pre_fence_text = text text = ensure_closed_code_fences(text) # A bare cursor renders as a stray tofu box 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: # Native streams MUST still get a finalize frame (placeholder) to # close the thinking bubble, e.g. for a MEDIA-only response. 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" # Don't open a new message for 1-2 tokens + cursor (rapid tool-calling): # if the cursor-strip edit is then rate-limited, "X ▉" stays forever. # Only first sends are 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: every frame goes through send_stream_frame(); the # adapter's send/edit paths are not touched in this mode. Lazy re-seed # here after a boundary closed the stream. if self._use_native_streaming and 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 self._awaiting_reopen_after_boundary = False # Paired with the boundary-finalize INFO: typing-reappear latency. 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: # WeCom renders each finalize as a separate bubble: only the # turn-final and boundaries close the stream, not segment breaks. if finalize and not is_turn_final: finalize = False if not finalize and text == self._last_sent_text: return True # unchanged — skip # Mark a finalize frame delivered OPTIMISTICALLY, before the ack # wait: the bytes hit the wire (and WeCom renders them) before the # ack, so a gateway join-cancel during the ack wait must not strand # final_content_delivered=False and cause a duplicate normal send # (docs/rca-wecom-stream-final-ack-timeout-duplicate.md). A # definitive dispatch failure rolls the mark back below. Residual # window (cancel between mark and wire write, sub-ms) is accepted. _optimistic_finalize = bool(finalize) if _optimistic_finalize: self._final_response_sent = True self._final_content_delivered = True # Recorded so a stale/partial frame can't suppress the corrective send. 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 # Definitive failure: roll back the optimistic mark so the # edit/send fallback delivers exactly once. if _optimistic_finalize: self._final_response_sent = False self._final_content_delivered = False self._delivered_final_text = None # Subsequent frames take the edit/send fallback; the adapter marks # the chat expired so it doesn't retry the dead stream. self._use_native_streaming = False # Best-effort close of an opened bubble (seed frame has zero length # but still opens it — hence _native_stream_opened, not pushed_len). 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 delivered: the frame closes the bubble but # WeCom may not render the content (errcode 6000 race). except Exception as e: logger.debug( "Native fallback: failed to finalize stream: %s", e, ) # Fall through so accumulated text still reaches the user via edit/send. # Drafts have no message_id: the final answer goes through the regular # send below (which clears the draft client-side). Skip drafts when # finalizing or when an edit path is already established. Exception: # stream-is-the-message adapters keep ONE stream per turn, so a # segment-break finalize must NOT become a real send (seal interception # would seal the stream at every tool boundary); only got_done 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)) ): _frame_text = _pre_fence_text if _stream_is_msg else text # Strip the cursor: native streams render their own indicator, and # "...text▉" is never a prefix of "...text more▉", which forces the # connector's whole-text re-append on EVERY tick (stacked copies). if self.cfg.cursor and _frame_text.endswith(self.cfg.cursor): _frame_text = _frame_text[: -len(self.cfg.cursor)] if _frame_text == self._last_sent_text: return True ok = await self._send_draft_frame(_frame_text) if ok: # Deliberately NOT _already_sent: the gateway's fallback final # send must still fire so the user gets a real message. return True # Failure disabled drafts; fall through to edit/send. self._last_edit_overflowed = False try: if self._message_id is not None: if self._edit_supported: # REQUIRES_EDIT_FINALIZE adapters need the finalize=True # edit even when unchanged; everyone else short-circuits. if text == self._last_sent_text and not ( finalize and self._adapter_requires_finalize ): return True # Fresh-final: replace a long-lived preview with a fresh # message (timestamp reflects completion), or whenever the # adapter prefers it (Telegram's send path renders richer # markdown than its edit path). An explicit hook returning # False must NOT be overridden by the time threshold — on # Telegram both messages would stay on screen since the # delete is best-effort. Check the CLASS (MagicMock # auto-creates attrs) plus instance __dict__ (test doubles # assign the hook explicitly). _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 self._track_preview_ids_from_result(result) # Oversized edit split across continuations: message_id # is now the LAST continuation, which holds only the # final chunk — retarget edits and reset skip-if-same. # getattr keeps SimpleNamespace test mocks working. _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 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 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 ): # Cosmetic final edit was rate-limited but the full # answer is already on screen (cursor stuck): mark # delivered so the gateway doesn't send it twice, # and record the on-screen payload. self._final_content_delivered = True self._record_turn_final_payload(text) raw_response = getattr(result, "raw_response", None) if isinstance(raw_response, dict) and raw_response.get("partial_overflow"): # Some overflow chunks landed but not the whole # response: preserve the visible prefix so got_done # sends the missing tail. 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 # Flood control: adaptive backoff (double the interval); # disable edits only after _MAX_FLOOD_STRIKES in a row. 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 ): self._last_edit_time = time.monotonic() # honor the new interval return False if immediate_final_fallback: logger.debug( "Turn-final edit hit flood control; " "entering fallback immediately" ) # Fallback mode: send only the missing tail at got_done. 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 # A turn-final flood skips the cosmetic cursor strip: it # would burn the same flood budget and delay the answer. if not immediate_final_fallback: await self._try_strip_cursor() return False else: return False # edits unsupported; fallback path sends the final else: # First send, threaded to the user's message (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 self._message_created_ts = time.monotonic() 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: no editable id, don't re-enter first-send # on every delta/tool boundary. self._message_id = "__no_edit__" self._notify_new_message() return True else: self._edit_supported = False return False except Exception as e: logger.error("Stream send/edit error: %s", e) return False