3683e70043
* feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half)
NS-658. Three additive ops within contract v1, emitted only when the
connector's negotiated descriptor advertises them:
{op: draft, chat_id, draft_id, content, final, metadata}
{op: task_card, chat_id, card_id, chunks, metadata}
{op: task_card_stop, chat_id, card_id, metadata}
The gateway side is deliberately dumb: no platform API knowledge, no new
config keys. Slack mechanics (chat.startStream/appendStream/stopStream,
per-workspace feature-gate cache, send+edit fallback) live connector-side
where the platform adapter lives in the relay model.
Semantic bridge: base send_draft is Telegram-shaped (draft clears; final
is a separate send). Slack native streaming makes the stream THE message.
The adapter tracks the open draft per chat and converts the turn-final
send() into draft(final=true) so the connector seals the stream instead
of posting a duplicate; the stream ts returns as the message identity.
A failed frame disarms interception so the edit-based fallback's real
send goes through untouched.
BEHAVIOR CHANGE (deliberate): relay supports_draft_streaming() now
requires the descriptor flag AND the draft op. Flag-only was a latent
lie — send_draft inherited NotImplementedError, so a connector setting
the flag without the op would have crashed the stream consumer's draft
path. supported_ops stays fail-open for legacy (pre-contract) ops;
draft/task_card did not exist pre-contract and must not fail open.
Task cards ride #85476's adapter-agnostic TurnRunner seam (hasattr on
send_native_task_card_progress); supports_native_task_cards() is the
descriptor probe. Connector half + E2E harness pair follow in the gg
repo.
* fix(relay): expose native_task_cards_enabled() on the relay adapter
Live-canary finding (Alice, staging): the TurnRunner's task-card lane
probes adapter.native_task_cards_enabled() (the native Slack adapter's
opt-in contract). The relay adapter only offered
supports_native_task_cards(), so the hasattr gate failed silently and
tool progress stayed on the text path — draft streaming worked, cards
never rendered. Alias it to the descriptor probe.
* fix(relay): match task-card methods to the TurnRunner's native keyword contract
Live-canary finding #2 (Alice, staging): gateway/run.py's card lane calls
send/stop_native_task_card_progress with the NATIVE Slack adapter's
signature (tasks/title/reply_to/metadata/fallback_text, keyword-only) —
PR 85796's relay methods took a positional card_id, so every call raised
TypeError('unexpected keyword argument reply_to') in the progress task,
repeatedly killing the card publisher (and the retry loop resent the
final delivery 4-5x). Card id now derives per turn thread
(turn:<reply_to>), thread_ts anchored like draft; title/fallback_text
accepted for parity, not forwarded (plan-mode stream renders chunks).
* fix(relay): one draft stream per turn for stream-is-the-message adapters
Live-canary finding #4 (Alice, staging): the stream consumer bumps
draft_id at every tool boundary so Telegram-shaped drafts animate each
text segment as a fresh preview. On relay Slack NATIVE streaming a new
draft_id opens a brand-new chat.startStream — the user saw one frozen
message per segment (stuck streaming cursor ▉, never sealed: only the
LAST stream gets the final=true seal) plus the real final; 5-6 cumulative
snapshots per turn. Adapters that mark draft_stream_is_message keep ONE
stream per turn: tool progress lives in the native task card, and the
connector's suffix-delta falls back to whole-text append on prefix
mismatch, so segments append cleanly. Telegram-shaped drafts keep the
per-segment bump.
* fix(relay): don't seal the native stream at tool boundaries — only the turn-final does
Live-canary finding #5 (Alice; supersedes the incomplete #4 which was
necessary but not sufficient). Root cause CONFIRMED by integration trace
(test_live_cards_flow_trace.py, real consumer semantics + real adapter +
stub transport): at every tool boundary the consumer calls
_send_or_edit(finalize=True), which skips the draft path and issues a
real send(); the relay adapter's seal-interception converts THAT into
draft(final=true) — sealing the stream once per segment. Timeline showed
3 seals for a 3-segment turn: exactly the frozen cumulative ▉ snapshots
seen live (the replaced stream never gets stopStream, keeping its cursor).
Fix: for draft_stream_is_message adapters, a segment-break finalize
(finalize=True, is_turn_final=False) stays ON the draft path as another
cumulative frame; only got_done (is_turn_final=True) falls through to
send() and seals. Telegram-shaped platforms unchanged. Trace test now
pins the invariant: ONE user-visible message per turn.
* fix(relay): strip the text cursor from native draft frames
Live-canary finding #6 (Alice) — the ACTUAL duplicate-content mechanism,
confirmed by full-flow scan of both sides' code + logs. The consumer
appends its text cursor (▉) to every non-final display_text tick. The
connector's stream sender diffs CUMULATIVE frames via prefix check:
'abc▉'.startsWith → 'abc def▉' is NEVER a prefix match (the cursor sits
mid-string), so deltaFor falls back to whole-text append on EVERY tick —
chat.appendStream stacks each full cumulative snapshot (cursor included)
into the ONE stream message. Exactly the observed thread: repeated
blocks, each ending in a frozen ▉, growing per tick.
Fixes #4/#5 were real (one stream per turn now) but this was the last
mechanism standing. Native streams render their own typing indicator, so
the text cursor is pure noise on this path: strip it from draft frames.
Prefix check now holds; every tick appends only its true suffix delta.
* fix(relay): seal-interception covers EVERY egress door, not just send()
Live-canary finding #7 (Alice): one duplication remained after #6 — the
stream froze mid-word with the live indicator (never sealed) and the
final posted as a separate message. Log receipt: 'Queued follow-up:
final text delivery confirmed; delivering explicit media before
continuing' — the turn's final went out via the DELIVERY RESOLVER lane
(gateway/delivery.py), which calls send_for_platform() DIRECTLY,
bypassing send() and its seal-interception. The open stream never
absorbed the final; it arrived as a plain 'send' op → chat.postMessage.
Fix: hoist the open-draft check to the top of send() (ahead of the
explicit-platform branch) AND add it to send_for_platform() — an open
native stream absorbs the turn-final regardless of which egress door it
arrives through. The stream IS the message.
* fix(relay): failed seal falls back to plain send (PR 85796 AI-review point 1)
A turn-final seal that fails at the transport must never swallow the
final answer: the stream consumer has already disabled the draft
transport for the run, so a failed _seal_open_draft returning
success=False meant the user got NOTHING. Both seal-interception sites
(send + send_for_platform) now fall through to the regular plain-send
path on seal failure, with a warning receipt. Also mitigates AI-review
point 2 (sticky _open_draft_by_chat after an abandoned turn): a stale
entry's failed seal no longer blocks the next turn's delivery.
* fix(relay): arm seal-interception optimistically; never disarm on ambiguous failure (audit G-D1)
Deep-audit defect G-D1 (HIGH): the outbound leg is at-most-once on the
wire but its ack channel is lossy — send_outbound timeout (30s) and
WS-drop 'failures' frequently mean the frame WAS delivered and the
connector stream is open. send_draft popped _open_draft_by_chat on any
failure, disarming seal-interception while the connector stream lived:
the turn-final went out as a plain send → orphaned mid-word stream +
complete duplicate final (intermittent; needs a drop/timeout inside the
draft window).
Fix: arm the entry BEFORE the transport call and keep it armed on
failure/exception. Safe in every case: sealing a non-existent stream
opens+seals a single complete message connector-side, and a truly failed
seal already falls back to plain send at both interception sites.
Stale-entry damage is self-healing (one warning + plain send).
* fix(relay): gateway-side sealed-draft tombstone — G-D1 arming must not resurrect sealed streams
Regression fix on G-D1 (live: 'worse than before' — escalating frozen
prefixes). Optimistic arming had no seal-awareness: a straggler frame
arriving AFTER the seal re-armed _open_draft_by_chat for the already-
sealed draft_id; the next send was converted to draft(final=true) on the
tombstoned connector key, which CLEARED the connector tombstone (final
frame = new-turn signal), re-opened a stream with cumulative content,
and left it frozen — repeating per straggler: 4-5 escalating frozen
snapshots. Mirror the connector: _sealed_draft_by_chat records the
sealed draft_id per chat (tombstoned BEFORE the seal's transport call);
send_draft for a sealed draft_id is a success no-op (content already in
the sealed message) and never arms. A new turn's fresh draft_id arms
normally.
* fix(relay): key stream/card state per (chat, turn anchor) — parallel turns must not collide (finding #10)
Live finding #10 (Alice; three concurrent turns in one flat DM): all
coordination state was keyed per CHAT on a one-active-turn assumption.
Three parallel turns produced: turn B's task card merged into turn A's
(both were card 'turn:root' — reply_to is None in flat DMs), B left
cardless, and _open/_sealed_draft_by_chat clobbered across writers (3x
duplicate finals on the last turn). Per-turn machinery was correct;
the keys were not.
Fix: _draft_key(chat, metadata) = chat + the turn's thread anchor
(inbound stamps thread_ts = event.thread_ts or ts on every top-level
message, so each turn has one even in flat DMs). draft arming, seal
tombstones, both interception sites, and the task-card id all derive
from the same anchor. New trace test pins two interleaved turns:
distinct cards, own-stream seals, no leaked plain send, no cross-turn
tombstone drops (289 tests green).
* fix(gateway): preserve cumulative native stream across tools
* fix(gateway): consumer-declared final — the seal carries the true final
Three composed fixes for the Slack live-cards duplicate-final class:
1. finish(final_text): TurnRunner passes the completed final_response
(verifier footer, completion explainer included) as the authoritative
finalize payload. The native-stream seal delivers the TRUE final, so
post-stream mutation no longer forks a corrective plain send (#11).
2. Interim-send contract: commentary and segment-tail sends carry a
gateway-internal _interim_send marker; relay seal-interception skips
them at both egress doors. A mid-turn interim send can no longer seal
the live stream and orphan the real final into a duplicate.
3. Queued-follow-up lane reconciles an unconfirmed final by EDITING the
consumer's delivered message in place (sealed stream = regular
message, chat.update live-verified); plain send only as fallback.
This was the actual duplicate lane in the parallel canaries — every
duplicated turn logged 'final stream delivery not confirmed; sending
first response' (subagent-completion queued inbound), not parallelism.
Also: draft frames stay prefix-stable gateway-side (no fence-closing, no
segment state reset, no commentary reset for stream-is-the-message
adapters; MagicMock-safe 'is True' guards).
* test+docs: streaming-contract coverage completeness + maintenance guidelines
Coverage: two gaps closed on the consumer-declared-final contract —
(1) send_for_platform (the delivery-resolver egress door) honors the
_interim_send contract: no seal, marker stripped before the wire;
(2) finish(final_text) on a turn that never streamed does not adopt the
final (delivery ownership stays with the gateway's normal send path for
non-streaming models / tool-only turns).
Docs: AGENTS.md 'Known Pitfalls' gains the streaming delivery contract —
the four invariants of stream-is-the-message adapters (prefix-stable
frames, consumer-declared final, interim-send marker, reconcile-by-edit),
each traced to its live incident, plus the live-probed Slack streaming
API ground truth and the MagicMock 'is True' guard-style note.
* fix(relay): seal transport failure must never silently lose the final (review B1)
Two halves of one silent-loss path, live-probed on the review branch:
1. adapter: _seal_open_draft did not catch transport exceptions. A socket
drop at seal time raised out of send(), skipping the fail-open plain
send entirely. Now: retry the SAME idempotent final frame once (the
connector's sealed-key tombstone returns the original stream ts for a
repeated final — a retry can never open a second stream or duplicate),
then report failure so the caller's fail-open path runs.
2. consumer: the turn-final retry (elif not _already_sent) called
_send_or_edit with finalize=False, which re-entered the DRAFT-FRAME
branch. Its no-op dedupe compared the adopted final against the last
unsealed frame, matched, and returned True with ZERO transport calls —
final_response_sent went green, delivered_final_matches reconciled,
the gateway suppressed its fallback, and the user never received the
answer. finalize=True keeps this retry out of the draft branch.
Regression suite: tests/gateway/test_relay_seal_failure.py (3 tests).
Mutation evidence in follow-up verification: reverting either half sends
the suite red.
* fix(relay): draft ids unique across gateway incarnations (review B3)
The relay connector tombstones sealed streams by (channel, draft_id) and
keeps up to 512 of them; they outlive the gateway process. Relay gateways
are disposable BY DESIGN (scale-to-zero), and _draft_id_counter restarted
at zero every incarnation — so the first turns after every scale-from-zero
in a recently-active channel replayed already-sealed wire identities. The
connector answered those frames straight out of the old tombstone: zero
Slack API calls, the OLD message ts returned as the new turn's identity,
the new answer silently dropped while gateway-side flags recorded success.
Seed the counter from wall-clock milliseconds at process start. Ids stay
plain ints within the existing contract op; incarnations cannot overlap
for realistic turn counts and restart gaps.
Regression: tests/gateway/test_draft_id_restart_uniqueness.py — the seed
test fails on the old code (seed 0 is not epoch-scale).
* fix(relay): stream/card state keyed per TURN, not per thread anchor (review B2)
The thread anchor is the wrong coordination identity — simultaneously:
- too coarse: two parallel turns replying INSIDE ONE Slack thread share
thread_ts. Live-probed on the review branch: turn A's final sealed turn
B's stream with A's content while A's own stream stayed open, and B's
final degraded to a plain send.
- too fragile: a flat DM with no thread metadata degraded to the bare
chat id, re-creating the original finding-#10 collision the anchor was
meant to fix.
_draft_key now prefers the triggering inbound message id (message_id /
reply_to_message_id — per-turn by construction; the gateway's Slack
thread metadata and the consumer's send path both stamp it), falling back
to the thread anchor, then the bare chat. The consumer stamps the same
reply_to_message_id on draft frames so frames and the turn-final resolve
to one key. Task-card ids share the derivation via _card_key (one helper
for send AND stop, so the stop always hits the stream the send opened).
Legacy resolver-lane callers with placement-only metadata still seal via
_match_open_draft's fallback — but ONLY when exactly one stream is open.
With several open, an identity-less send stays a plain send: a duplicate
message is recoverable, sealing someone else's stream is not.
Regression: tests/gateway/relay/test_relay_turn_keying.py (7 tests).
* fix(relay): stream-is-the-message is a Slack semantic, gate it on the descriptor (review B4)
draft_stream_is_message was hardcoded True on the relay adapter class,
i.e. for EVERY relay platform. The base send_draft contract is
Telegram-shaped — the draft clears client-side and the final arrives as
a separate real send that becomes the history message. With the flag
forced on, any non-Slack connector advertising the draft op had its
turn-final intercepted into draft(final=true): probed on the review
branch with a telegram descriptor, the op stream was
[draft(final=false), draft(final=true)] and NO send — no history message
would ever be posted.
Gate the flag on the negotiated descriptor platform (slack), and skip
arming seal-interception entirely when it is off. A future platform with
genuine stream-is-the-message native streaming should advertise it via
the descriptor rather than widening the platform check by guesswork.
Regression: tests/gateway/relay/test_relay_stream_semantics_gating.py
(4 tests: gating both ways, telegram final is a real send, slack final
still seals).
* fix(gateway): mark every mid-turn status lane interim — heartbeats must not seal the stream (review B5)
Seal-interception treats the first unmarked send to an armed (chat, turn)
key as the turn-final. The consumer's own interim lanes (commentary, tail
flush) carry _interim_send, but four gateway-side lanes that fire DURING
a streaming turn did not:
- long-running heartbeat (default every 180s — probed live: at 3 minutes
it sealed the live stream with '⏳ Working — 3 min', the real final
posted as a duplicate, and later frames were silently swallowed by the
seal tombstone)
- inactivity warning
- plain-text approval fallback (button lane failed)
- background-review notice
Add _interim_metadata() beside _non_conversational_metadata and wrap all
four call sites. The marker is gateway-internal; the relay adapter strips
it before the wire (existing behavior, pinned by test).
Note for follow-up: the opt-out shape remains fragile — any FUTURE
unmarked mid-turn send lane re-creates this bug. Inverting the contract
(explicitly mark the one turn-final send) is the durable fix but touches
every adapter's final-delivery path; deliberately kept out of this
review-fix series.
Regression: tests/gateway/test_interim_send_lanes.py (4 tests).
* fix(gateway): interrupted/incomplete turns must not adopt the diagnostic as the stream final (review B6)
The finish(final_text) adoption gate checked only 'not failed', but the
interrupt/abort returns in agent/conversation_loop.py are
{completed: False, interrupted: True, final_response: 'Operation
interrupted during …'} with NO failed key. Adopting that diagnostic:
1. sealed the user's streamed partial answer over with the interrupt
text (stream-is-the-message: the seal rewrites the whole message), and
2. recorded the diagnostic as the turn-final payload, so
delivered_final_matches reconciled and the gateway suppressed its own
error-delivery path — the diagnostic became the ONLY thing delivered.
Enumerated all 27 final_response-bearing return shapes in
conversation_loop.py: every non-happy-path shape carries completed:
False (several with a diagnostic final_response and neither failed nor
interrupted — retry exhaustion, truncation, codex-incomplete); the happy
path routes through turn_finalizer.finalize_turn (completed=True). Gate
is therefore: not failed AND not interrupted AND completed is not False.
Results lacking the completed key entirely (older callers/test doubles)
keep the previous behavior.
Regression: tests/gateway/test_stream_final_adoption_gate.py (6 tests,
incl. a source-level pin on the run.py call site).
* fix(relay): task-card transport failures degrade to failed SendResults (review B7)
send_native_task_card_progress and stop_native_task_card_progress let
transport exceptions escape. The stop runs inside the progress loop's
finally block on the turn-cleanup path, and the post-cancel awaits in
gateway/run.py caught only CancelledError — a socket drop during a card
publish/stop therefore aborted cleanup BEFORE the final-delivery
bookkeeping ran.
Three layers, outermost defends any adapter:
- both adapter methods catch transport exceptions and return failed
SendResults (progress is advisory; the TurnRunner's text fallback
already handles failure results)
- the progress loop's finally wraps the stop (best-effort; the connector
seals orphaned card streams on its own via recycling/eviction)
- the cleanup awaits log-and-continue on non-cancellation errors so
final-delivery bookkeeping always runs
Regression: tests/gateway/relay/test_relay_task_card_failures.py.
* fix(relay): a dying turn seals its native stream instead of orphaning it (review B8)
Stale-generation exits (/new, /stop mid-stream) and cancellations
returned from the consumer's run() with the native stream still open:
- the Slack message kept its live streaming indicator forever (the
cancellation best-effort edit only runs when _message_id exists, and
the native draft path deliberately keeps it None);
- the adapter's armed interception state survived the turn, so the next
turn on the same key could inherit it and seal a dead draft_id.
New adapter op abandon_open_draft(chat, content): seals in place with
the text already on screen (the consumer passes its last delivered
frame) — the seal adds nothing and claims nothing; delivery flags are
never set, so the gateway's normal paths still own whatever happens
next. Best-effort by contract (failure reported, never raised); the
connector reaps truly orphaned streams via recycling/eviction.
The consumer calls it from both death paths: the stale-generation early
return and the CancelledError handler.
Regression: tests/gateway/test_stream_abandon_on_turn_death.py (4 tests,
incl. the next-turn-inheritance hazard).
* fix(relay): bound the draft/seal coordination dicts (review M1)
_sealed_draft_by_chat's key embeds a per-turn identity, so every
completed turn wrote a permanent entry — unbounded growth for the life
of a long-running gateway process (the docstring said 'one entry per
chat', which stopped being true when the key gained the turn anchor).
_open_draft_by_chat could grow the same way via abandoned entries.
FIFO-evict both at 512 entries — the same idiom as the sibling bounded
cache (_auto_thread_by_chat, capped at 256) and the same size as the
connector's own tombstone store. The straggler window the tombstone
exists for is seconds long; FIFO is more than enough.
Regression: tests/gateway/relay/test_relay_state_bounds.py.
* fix(relay): explicit connector rejection disarms interception; exceptions stay armed (review P3)
The G-D1 optimistic-arming change silently dropped disarm-on-failure
entirely: after an EXPLICIT connector rejection (success=False result —
not a transport ambiguity), interception stayed armed even though the
stream consumer disables the draft transport on that failure and falls
back to edit-based streaming. Its turn-final would then be converted
into a seal on a stream the connector just told us is unusable.
test_draft_failure_result_propagates claimed to cover this ('must NOT
leave seal-interception armed') but passed for an unrelated reason: the
stub's canned failure also failed the SEAL, whose fail-open path did the
plain send.
Split the two semantics and pin each honestly:
- explicit rejection (result success=False): disarm — turn-final is a
real send (test_draft_failure_result_propagates, now testing what its
comment says)
- transport exception: ambiguous, stay armed — turn-final still seals
(test_draft_transport_exception_keeps_interception_armed, the G-D1
contract)
Also corrects commit ba3a24a's claim ('a failed frame disarms
interception so the edit-based fallback's real send goes through
untouched') to hold again for the rejection case it described.
* fix(relay): lost acks are ambiguous, not rejections — on the RESULT channel too (review r2, finding 1)
The production ws transport does not raise on ack timeout — it returns
{"success": False, "error": "relay outbound timed out"}. The round-1
ambiguity handling keyed entirely on the exception channel, so the shape
production actually produces was misclassified as a definite connector
rejection. Probed on the head:
- lost SEAL ack: skipped the idempotent retry, fell straight to a plain
send — duplicate final whenever the seal had actually applied;
- lost FRAME ack: the round-1 disarm-on-rejection fired — interception
disarmed, frozen native stream beside a plain final. This re-created
the original G-D1 ambiguous-ack defect on the result channel.
Contract now spans both channels:
- transport: the ack-timeout branch tags ambiguous=True. The fail-fast
branches (closing / not connected) never sent anything and stay
unmarked — they are definite non-delivery.
- adapter frame path: ambiguous results keep interception armed (same
as exceptions); only definite rejections disarm.
- adapter seal path: one shared _attempt() classifier — exception and
ambiguous result both mean "unknown"; the SAME idempotent frame is
retried once (connector tombstone returns the original stream ts for
a repeated final). Only after both attempts stay ambiguous does the
caller's fail-open plain send run: a possible duplicate after double
ack loss beats a silent loss, and double ack loss on one socket
almost always means the transport is down for the plain send too.
Regression: tests/gateway/relay/test_relay_ack_ambiguity.py (6 tests,
incl. a source-of-truth check that the transport tags the timeout branch
and leaves fail-fast branches unmarked).
* fix(relay): stream semantics + draft capability resolve per CHAT, not per primary (review r2, finding 2)
One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate
per platform on the transport and egress is tagged per chat — but the
round-1 gate keyed draft_stream_is_message and supports_draft_streaming()
off the PRIMARY scalar descriptor. Probed on the head:
- Slack primary + Telegram chat: the Telegram chat's turn-final was
intercepted into draft(final=true) — no real Telegram history message;
- Telegram primary + Slack chat: the Slack chat was denied native
streaming entirely.
Resolve both through _descriptor_for_chat — the same per-chat machinery
max_message_length already uses (added for the identical class of bug:
the primary's 39000-char cap over-sending into Discord 400s):
- new stream_is_message_for_chat(chat_id) on the adapter; arming and
NotImplementedError gating use it. The class attribute remains as the
single-platform value and legacy-probe fallback.
- supports_draft_streaming() gains an optional chat_id kwarg (base
signature updated; single-platform adapters ignore it). The consumer
passes chat_id with a TypeError fallback for out-of-tree adapters.
- the consumer's four draft_stream_is_message reads collapse into one
_stream_is_message() helper that prefers the per-chat probe
(class-resolved, MagicMock-safe) over the attribute.
Platform-name inference ("slack") stays deliberate: a descriptor-level
semantic field is the right eventual contract but is a cross-repo wire
change — noted for the gg follow-up so future platforms advertise the
semantic explicitly.
Regression: tests/gateway/relay/test_relay_multiplatform_semantics.py
(5 tests: both starvation directions, scalar fallback, per-chat
capability gate).
* fix(gateway): split delivery + authoritative footer reconciles by suffix, not full resend (review r2, finding 3)
The _FINAL_TEXT adoption guard refuses wholesale adoption on split turns
— correct (#78541: sealed heads would repeat inside the tail) but it was
absolute: a post-split verifier footer never entered the ledger,
delivered_final_matches() reported a mismatch, and the gateway resent
the ENTIRE body+footer after the split chunks (the #11 duplicate class,
one level up).
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 —
a rewrite cannot be patched onto sealed heads.
Regression: tests/gateway/test_split_final_suffix_reconcile.py (3 tests:
suffix rides the tail + reconciles, rewrite still mismatches, unsplit
adoption unchanged).
* fix(relay): cancellation mid-seal restores open state so abandon can close the stream (review r2, finding 4)
_seal_open_draft pops the open entry and writes the local tombstone
BEFORE awaiting transport I/O — correct ordering for the straggler race,
but CancelledError is not an Exception: a cancel during the await
bypassed all failure handling, leaving the remote stream live (visible
streaming indicator until connector eviction) while the local state said
'nothing open'. The consumer's abandon pass — added for exactly this
turn-death case — found nothing to close and no-oped.
On CancelledError: restore the open entry, drop the premature tombstone
(only if it is still ours), re-raise. The abandon path then seals the
stream in place with the on-screen text.
Regression: tests/gateway/relay/test_relay_seal_cancellation.py (2
tests: state restoration, and end-to-end cancel→abandon→remote seal).
* fix(relay): thread anchors are placement, not turn identity — revive the placement-only fallback (review r2, finding 5)
_match_open_draft's single-open-stream fallback was dead for its primary
intended callers: metadata carrying thread_ts/thread_id (placement-only
resolver lanes) was classified as having 'turn identity', so those sends
never reached the fallback — probed: a plain final posted beside the
still-open turn-keyed stream.
Only per-turn MESSAGE ids are identity now. Thread-anchored and bare
callers share the fallback: absorb into the chat's open stream when
EXACTLY one is open; stay a plain send when several are (duplicate is
recoverable, wrong-stream seal is not). Callers WITH a message id whose
key misses never fall back — their identity is authoritative and a miss
means the stream belongs to a different turn.
Regression: 4 new tests in test_relay_turn_keying.py (thread-anchored
seal, both ambiguous-stay-plain shapes, id-mismatch never steals).
* fix(relay): random process nonce for draft-id seeding (review r2, follow-up 6)
The epoch-millisecond seed (round-1 B3 fix) mitigates the restart-replay
class but is not a uniqueness guarantee: two gateways starting in the
same millisecond, a forked process inheriting the class state, or a
clock step backwards can all mint colliding wire identities against the
connector's per-(channel, draft_id) tombstone store.
Seed from secrets.randbits(49) instead: collision probability negligible,
no clock dependence, and ids + realistic per-process turn counts stay
comfortably inside the connector's JS number range (draft_id?: number,
2^53). Regression test now spawns two real interpreters and asserts
their seeds differ — the exact scale-to-zero restart shape, and both
start within the same second so a clock-locked seed would fail it.
* fix(relay): stamp per-turn Slack egress identity — cache is fallback only (R3-5)
The connector (gateway-gateway#210) fills chat.startStream's
recipient_user_id / recipient_team_id — required by Slack when
streaming to a channel — from metadata.user_id / metadata.scope_id.
The gateway stamped only slack_team_id per-turn and left user_id (and
scope_id) to RelayAdapter._with_scope, whose per-chat caches are keyed
on chat_id alone and overwritten by every inbound message: with users
U1 and U2 running overlapping turns in one channel, U2's arrival
overwrote the cache before U1's stream opened, and U1's stream carried
U2 as recipient_user_id.
_thread_metadata_for_source now stamps scope_id and user_id from the
turn's OWN source (setdefault — explicit values win), so identity is
turn-scoped data on the wire. _with_scope is unchanged and fill-only:
the caches keep serving restart/synthetic sends that carry no per-turn
identity, which is all they were ever safe for.
Mutation evidence: reverting the run.py hunk sends
test_thread_metadata_stamps_per_turn_user_and_scope and
test_concurrent_turns_carry_their_own_identity red; restore returns
green. The _with_scope fill-only tests pass on both trees (existing
correct behavior, now pinned against regression).
---------
Co-authored-by: Ben Barclay <ben@nousresearch.com>
2687 lines
136 KiB
Python
2687 lines
136 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 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()
|
|
# 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()
|
|
|
|
|
|
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
|
|
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
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
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
|
|
return None
|
|
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 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 _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()
|
|
# #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._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
|
|
|
|
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 native draft streaming once per run. When enabled the
|
|
# consumer routes mid-stream frames through adapter.send_draft and
|
|
# leaves _message_id=None so the existing got_done path delivers the
|
|
# final answer as a regular sendMessage (drafts have no message_id
|
|
# to edit).
|
|
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
|
|
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 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
|
|
self._filter_and_accumulate(item)
|
|
except queue.Empty:
|
|
break
|
|
|
|
# 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:
|
|
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
|
|
# 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:
|
|
# Split overflow: if accumulated text exceeds the platform
|
|
# limit, split into properly sized chunks.
|
|
if (
|
|
_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:
|
|
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.
|
|
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()
|
|
|
|
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._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
|
|
)
|
|
):
|
|
# Mid-stream edit above already delivered the
|
|
# final accumulated content. Skip the redundant
|
|
# final edit for adapters that don't need an
|
|
# explicit finalize signal, and for any adapter
|
|
# when that edit split-and-delivered across
|
|
# continuations: the split edit carried
|
|
# finalize=True itself, and re-finalizing with
|
|
# the full text would overflow-split again into
|
|
# the adopted continuation, duplicating chunks
|
|
# on screen.
|
|
#
|
|
# 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()
|
|
else:
|
|
self._reset_segment_state()
|
|
await self._send_commentary(commentary_text)
|
|
self._last_edit_time = time.monotonic()
|
|
self._reset_segment_state()
|
|
|
|
# Tool boundary: reset message state so the next text chunk
|
|
# creates a fresh message below any tool-progress messages.
|
|
#
|
|
# 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.
|
|
if (
|
|
self._stream_is_message()
|
|
and self._use_draft_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 == "ambiguous":
|
|
# A timeout may mean Telegram accepted the send but the
|
|
# client never received the response. Preserve duplicate
|
|
# suppression for that one uncertain outcome.
|
|
self._final_content_delivered = 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, and ``ambiguous`` when a timeout may have
|
|
reached the platform already.
|
|
"""
|
|
# 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,
|
|
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
|
|
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:
|
|
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
|
|
|
|
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 = dict(self.metadata) if self.metadata else {}
|
|
_md["_interim_send"] = True
|
|
result = await self.adapter.send(
|
|
chat_id=self.chat_id,
|
|
content=text,
|
|
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:
|
|
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
|
|
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.
|
|
"""
|
|
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._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:
|
|
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 draft streaming: route mid-stream frames through send_draft.
|
|
# 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
|