Skip to content

fix(gateway): finish the split-delivery bug class behind the swallowed-finals fix (salvages #78558) - #79669

Merged
kshitijk4poor merged 3 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/78558-split-delivery-swallow
Aug 5, 2026
Merged

fix(gateway): finish the split-delivery bug class behind the swallowed-finals fix (salvages #78558)#79669
kshitijk4poor merged 3 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/78558-split-delivery-swallow

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Summary

Salvages #78558 (@HexLab98) onto current main and finishes the bug class it opened.

The original fix is correct about the cause: on a multi-message split delivery the stream consumer set final_content_delivered=True without recording a payload, delivered_final_matches() returned None, and gateway/run.py treated None as "trust the flag" — so the completed reply was suppressed and never sent. That's #78541: 165 suppressed events with 100–3000 char replies lost over 48h across three Telegram groups.

But six code paths set _turn_split_delivery, and only one was taught to record a payload. The other five inherited the new "payload-less split ⇒ mismatch" verdict without being able to satisfy it, which swapped the swallow for the opposite defect. This PR fixes the producers rather than only distrusting them at the boundary.

Context — what actually changed for a user

Before (on main): a long Telegram group reply that overflows into several messages, where the stream ends before the tail arrives. Log shows the reply is ready, and it is never sent:

INFO gateway.run: Suppressing normal final send for session agent:main:telegram:group:-1004492624436:1: final delivery already confirmed (streamed=True previewed=False content_delivered=True).
INFO gateway.run: response ready: platform=telegram chat=-1004492624436 time=55.0s api_calls=2 response=1939 chars

The 1939-char answer never reaches the chat. Reproduced with a probe driving the real run() loop:

BEFORE (main)                      AFTER (this PR)
user saw the tail? : False         user saw the tail? : False
matcher verdict    : None          matcher verdict    : False
=> SUPPRESSES?  True               => SUPPRESSES?  False
   reply swallowed (#78541)           complete reply is delivered

What #78558 alone would have shipped. Same probe harness, three shapes it does not cover:

shape main #78558 alone this PR
complete overflow split, everything delivered suppress ✅ suppress ✅ suppress ✅
split + flood-controlled cosmetic final edit suppress ✅ re-send ❌ duplicate suppress ✅
split + fallback final send (tail-only record) suppress ✅ re-send ❌ duplicate suppress ✅
split on Telegram's fresh-final route swallow ❌ swallow ❌ delivered ✅

The duplicate on row 2 is the regression that matters most: it re-opens the double-answer #45517 fixed (#36965 / #25349). Row 4 is why the reported symptom would likely have persisted — _try_fresh_final is Telegram's default finalize route.

Changes

Kept from #78558 (@HexLab98's two commits, authorship preserved): _stream_ledger (an un-truncated mirror of _accumulated, since the overflow paths truncate _accumulated to the tail once heads are sealed), the False-on-payload-less-split verdict, and the tri-state reordering. Confirmed the ledger is genuinely load-bearing — nothing else records the union of a multi-message delivery: _delivered_segment_texts is only appended in _reset_segment_state (which the split paths never reach; they clear _last_sent_text first), and has_delivered_text compares whole-string equality per entry, so it structurally cannot match chunk1 + chunk2.

Added on top:

  • _send_or_edit failed-final-edit branch — record the visible payload on split turns too. It deliberately skipped recording, which now reads as a mismatch and re-sends an answer already on screen.
  • _send_fallback_final (×2) + _send_empty_fallback_final — route through _record_turn_final_payload instead of assigning _delivered_final_text directly. On a split turn their final_text is only the trailing chunk, so a fully delivered heads+tail reply recorded a tail-only payload and was re-sent in full.
  • _try_fresh_final — refuse the fresh-final route once a head chunk is sealed. It replaces every tracked preview with one message, which only holds the whole answer on a single-message turn; after a split it deleted the sealed heads while sending just the tail.
  • Set _turn_split_delivery at seal time rather than after the tail send, so the tail's own finalize sees the split state. The sibling overflow path already did this — that divergence is what let fresh-final delete the heads.
  • run.py stale-finalize reconciliation — skip the in-place edit on a split delivery. message_id is only the last chunk there, so editing it with the complete response repeated every sealed head's text inside the tail message. Falls through to the normal final send.
  • Dropped a dead or "".join(chunks) fallback (all growth funnels through _append_accumulated, so the ledger is never empty at that call site; and joined chunks carry injected fence markers that could never equal final_response), and documented that _record_turn_final_payload intentionally ignores its argument on split turns.

Validation

result
tests/gateway/test_stale_finalize_suppression.py 16 passed
tests/gateway/ -k 'stream or consumer or final or deliver or suppress or overflow or split or telegram' 1062 passed, 1 skipped (pre-existing mautrix mock skip)
ruff + py_compile on all three files clean
probes vs main, real run() loop 7/7 shapes correct (see table above)

Mutation-checked — reverting any individual fix turns its own test red, so none of the new tests are vacuous:

mutation result
revert _try_fresh_final split guard test_split_delivery_keeps_sealed_heads_on_fresh_final fails
revert failed-final-edit recorder test_failed_final_edit_after_split_records_visible_payload fails
revert delivered_final_matches to pre-PR 7 tests fail

Four new tests drive the real overflow-split loop rather than hand-setting private flags: complete split still suppresses (no duplicate), split missing a tail does not suppress, fresh-final keeps sealed heads, and a flood-controlled final edit after a split stays suppressed.

One pre-existing assertion relaxed: the gateway-boundary test asserted the recovery route (that _run_agent performs the reconcile edit) rather than the guarantee. Since a split delivery now deliberately skips that edit, it asserts the real contract — either _run_agent puts the complete text on the wire, or it declines to claim delivery so the caller's normal final send does. Verified the latter holds: already_sent comes back falsy.

Credit

@HexLab98 diagnosed the root cause and wrote the ledger mechanism; both commits are preserved with authorship intact.

Also closes #78556 (@686f6c61), which fixed the same issue independently. Its approach records "".join(chunks[:-1]) + _accumulated, but the splitter strips the newline separators (.lstrip("\n")) and injects fence markers at boundaries, so the join can never equal final_response — verified it returns False on every complete overflow split, i.e. duplicate sends on all long replies. Credited for independent diagnosis.

Closes #78541

HexLab98 and others added 3 commits August 6, 2026 02:25
Record an unsplit stream ledger for multi-message deliveries and refuse
legacy trust when split delivery left no payload, so Telegram group
sessions no longer suppress a complete reply after an early/partial
finalize (NousResearch#78541).
Add unit and GatewayRunner boundary coverage for the NousResearch#78541 shape where
final_content_delivered is set via split delivery with no recorded
payload.
…uplicate or still swallow

The salvaged fix changed only the gateway's verdict: a payload-less
multi-message split stopped inheriting legacy trust. But six code paths set
_turn_split_delivery, and only one of them was taught to record a payload, so
the remaining five swapped the swallow for the opposite defect.

Fix the producers instead of only distrusting them at the boundary:

- _send_or_edit failed-final-edit branch: record the visible payload on split
  turns too. It deliberately skipped recording, which now reads as a mismatch
  and re-sends an answer already on screen -- reintroducing the duplicate
  NousResearch#45517 fixed (NousResearch#36965 / NousResearch#25349).
- _send_fallback_final (x2) and _send_empty_fallback_final: route through
  _record_turn_final_payload instead of assigning _delivered_final_text
  directly. On a split turn their final_text is only the trailing chunk, so a
  fully delivered heads+tail reply recorded a tail-only payload and was
  re-sent in full.
- _try_fresh_final: refuse the fresh-final route once a head chunk is sealed.
  It replaces every tracked preview with one message, which only holds the
  whole answer on a single-message turn. After a split it deleted the sealed
  heads while sending just the tail, so the complete reply was still lost --
  on Telegram, the default finalize route and the shape NousResearch#78541 reports.
- Set _turn_split_delivery at seal time rather than after the tail send, so
  the tail's own finalize sees the split state. The sibling overflow path
  already did this; the divergence is what let fresh-final delete the heads.
- run.py stale-finalize reconciliation: skip the in-place edit on a split
  delivery. message_id is only the LAST chunk there, so editing it with the
  complete response repeated every sealed head's text inside the tail
  message. Fall through to the normal final send.

Also drop a dead `or "".join(chunks)` fallback (all growth funnels through
_append_accumulated, so the ledger is never empty at that call site, and joined
chunks carry injected fence markers that could never match final_response), and
document that _record_turn_final_payload intentionally ignores its argument on
split turns.

Tests: four end-to-end cases driving the real overflow-split loop instead of
hand-setting private flags -- complete split still suppresses (no duplicate),
split missing a tail does not suppress, fresh-final keeps sealed heads, and a
flood-controlled final edit after a split stays suppressed. Each was
mutation-checked: reverting any individual fix turns its test red.

The pre-existing gateway-boundary test asserted the recovery *route* (the
reconcile edit) rather than the guarantee. Relaxed to the real contract: either
_run_agent puts the complete text on the wire, or it declines to claim delivery
so the caller's normal final send does.

Co-authored-by: HexLab98 <liruixinch@outlook.com>
@kshitijk4poor
kshitijk4poor merged commit 392e3a8 into NousResearch:main Aug 5, 2026
39 checks passed
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 5, 2026
kshitijk4poor added a commit that referenced this pull request Aug 5, 2026
…ty-fallback recovery

Follow-up to #79669. That PR routed the three fallback recorder sites through
_record_turn_final_payload so a split turn would record the unsplit ledger
instead of a tail-only payload. For two of them that is right. For
_send_empty_fallback_final it is wrong, and it reintroduces the #78541 swallow
at the one site that was supposed to be fixed.

_send_empty_fallback_final is a *replacement* recovery: it sends the completed
text as a fresh message and deletes every tracked segment preview -- which on an
overflow split includes the sealed head chunks. After it runs, the only thing
on screen is the message it just sent. Recording the ledger there claims
delivery for text the same function just removed, so delivered_final_matches()
returns True, the gateway suppresses its own send, and the user is left with a
fraction of the answer.

Observed with a probe driving the real run() loop (543-char reply, 475-char head
sealed then deleted, 67-char tail committed):

  before this fix        recorded=543  matches=True   -> suppressed, 67/543 on screen
  after  this fix        recorded=67   matches=False  -> gateway sends the full answer

Record final_text verbatim here instead. The sibling site in
_send_fallback_final keeps the recorder: its delete is gated on
`continuation == final_text` and targets only the single active partial, never
the sealed heads, so the ledger correctly describes what survives.

The distinction is whether a recovery ADDS to what is on screen or REPLACES it.
Additive paths may record the ledger; replacing paths must record only what they
leave behind. _try_fresh_final is the same shape and #79669 handled it by
refusing the route on split turns.

Test drives the real seal-then-delete sequence and asserts the mismatch, so the
gateway is required to re-send. Mutation-checked: restoring the recorder call
turns it red.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ty-fallback recovery

Follow-up to NousResearch#79669. That PR routed the three fallback recorder sites through
_record_turn_final_payload so a split turn would record the unsplit ledger
instead of a tail-only payload. For two of them that is right. For
_send_empty_fallback_final it is wrong, and it reintroduces the NousResearch#78541 swallow
at the one site that was supposed to be fixed.

_send_empty_fallback_final is a *replacement* recovery: it sends the completed
text as a fresh message and deletes every tracked segment preview -- which on an
overflow split includes the sealed head chunks. After it runs, the only thing
on screen is the message it just sent. Recording the ledger there claims
delivery for text the same function just removed, so delivered_final_matches()
returns True, the gateway suppresses its own send, and the user is left with a
fraction of the answer.

Observed with a probe driving the real run() loop (543-char reply, 475-char head
sealed then deleted, 67-char tail committed):

  before this fix        recorded=543  matches=True   -> suppressed, 67/543 on screen
  after  this fix        recorded=67   matches=False  -> gateway sends the full answer

Record final_text verbatim here instead. The sibling site in
_send_fallback_final keeps the recorder: its delete is gated on
`continuation == final_text` and targets only the single active partial, never
the sealed heads, so the ledger correctly describes what survives.

The distinction is whether a recovery ADDS to what is on screen or REPLACES it.
Additive paths may record the ledger; replacing paths must record only what they
leave behind. _try_fresh_final is the same shape and NousResearch#79669 handled it by
refusing the route on split turns.

Test drives the real seal-then-delete sequence and asserts the mismatch, so the
gateway is required to re-send. Mutation-checked: restoring the recorder call
turns it red.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…ty-fallback recovery

Follow-up to NousResearch#79669. That PR routed the three fallback recorder sites through
_record_turn_final_payload so a split turn would record the unsplit ledger
instead of a tail-only payload. For two of them that is right. For
_send_empty_fallback_final it is wrong, and it reintroduces the NousResearch#78541 swallow
at the one site that was supposed to be fixed.

_send_empty_fallback_final is a *replacement* recovery: it sends the completed
text as a fresh message and deletes every tracked segment preview -- which on an
overflow split includes the sealed head chunks. After it runs, the only thing
on screen is the message it just sent. Recording the ledger there claims
delivery for text the same function just removed, so delivered_final_matches()
returns True, the gateway suppresses its own send, and the user is left with a
fraction of the answer.

Observed with a probe driving the real run() loop (543-char reply, 475-char head
sealed then deleted, 67-char tail committed):

  before this fix        recorded=543  matches=True   -> suppressed, 67/543 on screen
  after  this fix        recorded=67   matches=False  -> gateway sends the full answer

Record final_text verbatim here instead. The sibling site in
_send_fallback_final keeps the recorder: its delete is gated on
`continuation == final_text` and targets only the single active partial, never
the sealed heads, so the ledger correctly describes what survives.

The distinction is whether a recovery ADDS to what is on screen or REPLACES it.
Additive paths may record the ledger; replacing paths must record only what they
leave behind. _try_fresh_final is the same shape and NousResearch#79669 handled it by
refusing the route on split turns.

Test drives the real seal-then-delete sequence and asserts the mismatch, so the
gateway is required to re-send. Mutation-checked: restoring the recorder call
turns it red.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suppressing normal final send swallows complete replies on Telegram group/forum sessions (payload-less split-delivery flags final_content_delivered)

3 participants