fix(gateway): flush the undelivered tail when the first send fails, not just an edit - #80990
Open
briandevans wants to merge 1 commit into
Open
briandevans wants to merge 1 commit into
briandevans wants to merge 1 commit into
Conversation
…ot just an edit `_reset_segment_state` clears `_message_id` at every tool boundary, so the next text segment re-enters the first-send branch of `_send_or_edit`. That branch sets `_edit_supported = False` and returns without ever assigning `_message_id`, leaving it `None`. The NousResearch#8124 recovery flush was gated on `self._message_id` being truthy, which is exactly the condition a failed first send can never satisfy. The flush was therefore skipped and `_reset_segment_state` wiped `_accumulated` -- the only copy of prose the user never saw. The commentary reset had no guard at all and dropped the same buffer. Route both resets through one helper that keeps the `__no_edit__` exclusion (where the reset deliberately preserves state for `_send_fallback_final`) but drops the `_message_id` truthiness term. Symptom: on a multi-tool turn where one send is rejected, the paragraph written between two tool calls never appears and is never re-sent, so the reply jumps from tool bubble to tool bubble with the explanation missing.
There was a problem hiding this comment.
Pull request overview
This PR closes a remaining content-loss edge case in the gateway streaming consumer by ensuring buffered assistant text is flushed before segment state is reset when the initial send fails (i.e., no _message_id is ever assigned). It complements the earlier #8124 fix that handled failed edits mid-segment by extending the same “flush-before-reset” protection to the failed first send path and to the commentary reset.
Changes:
- Add a shared
_flush_undelivered_tail_before_reset()helper that flushes_accumulatedwhen it was never made visible, without requiring a truthy_message_id(while still respecting the__no_edit__sentinel). - Route both the segment-break reset and the commentary reset through the helper to avoid wiping the only copy of undelivered text.
- Add a focused regression test suite covering tool-boundary loss, commentary-reset loss, and guarding against
__no_edit__double-sends.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
gateway/stream_consumer.py |
Introduces and applies a shared flush-before-reset helper to prevent silent loss of buffered text when the first send fails. |
tests/gateway/test_stream_consumer_first_send_tail.py |
Adds regression coverage for failed-first-send tail preservation across tool boundaries and commentary resets, plus a __no_edit__ non-duplication guard. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a sibling follow-up to commit
1d1e1277e(issue #8124)1d1e1277ecovered: a failed edit mid-segment. When an edit was rejected (flood control) and a tool boundary arrived before the retry, the guard at thegot_segment_breakreset flushed_accumulatedas a fresh message so the pre-boundary tail was not lost._message_idis never assigned; and the commentary reset, which has no guard at all._message_idtruthiness term while keeping the__no_edit__exclusion, plus a regression that pins the sentinel so the fix cannot start double-sending.What does this PR do?
_reset_segment_statesetsself._message_id = Noneat every tool boundary, so the next text segment enters the first-send branch of_send_or_edit. When that send fails, the branch does:_message_idis never assigned, so it staysNone._last_sent_textis never assigned either (that happens above the failure return), so_reset_segment_state's_delivered_segment_textsbookkeeping records nothing.The #8124 recovery flush was gated on:
The
self._message_idterm is exactly the condition a failed first send can never satisfy, so the flush was skipped and the reset immediately wiped_accumulated— the only copy of the text. The commentary reset a few lines above (if commentary_text is not None: self._reset_segment_state()) had no guard at all and destroyed the same buffer.Symptom: on a multi-tool turn where one send is rejected, the paragraph written between two tool calls ("Here's what I found, let me check X") never appears and is never re-sent. The final answer still lands, so the reply visibly jumps from tool bubble to tool bubble with the explanation missing. Every chat user on the default
transport: "edit"path is exposed; no unusual config or exotic state is required.The existing helper needs no change and is already safe with
_message_id = None:_try_strip_cursor()early-returns on a falsy id, and_visible_prefix()returns""when_last_sent_textis"", so the prefix trim is skipped and the full buffer is sent as a new message. Only the caller's guard was excluding it.Related Issue
Relates to #8124 (closed;
1d1e1277ewas its fix — this completes the same guard for the first-send path).Type of Change
Changes Made
gateway/stream_consumer.py— new_flush_undelivered_tail_before_reset()next to_flush_segment_tail_on_edit_failure(). It keeps the_accumulated/current_update_visible/__no_edit__conditions and drops only the_message_idtruthiness term.gateway/stream_consumer.py— thegot_segment_breakreset now calls the helper instead of carrying the inline guard; the surrounding#8124comment is updated to say the flush covers a failed first send as well as a failed edit.gateway/stream_consumer.py— the commentary reset calls the helper before_reset_segment_state().tests/gateway/test_stream_consumer_first_send_tail.py— new, 3 tests (below).Sibling-site sweep
grep -n "_reset_segment_state" gateway/stream_consumer.py→ 5 hits. Every call site is accounted for::285:552:1122(commentary, pre-send):1125(commentary, post-send)_send_commentarydelivered;_accumulatedis already empty:1157(segment break)Plus the manual reset inside
_suppress_silence_marker(~:2047), which is excluded on purpose: it is a deliberate retraction of the preview when the agent emits a bare[SILENT]/NO_REPLYmarker, and it even deletes the previously-sent preview messages. Flushing there would resurrect text the agent chose not to send.How to Test
Three tests, all asserting on delivered payloads rather than internal attributes. The fake adapter fails only the editable-preview send path (the one the consumer marks with
expect_editsin_metadata_for_send) and accepts plain sends — mirroring a real platform split, since Telegram keeps editable previews on the legacy send path.test_failed_first_send_tail_survives_tool_boundary— deltas → tool boundary → deltas → done. Every preview send fails, so_message_idis stillNoneat the boundary.test_failed_first_send_tail_survives_commentary_reset— same, driven through the commentary path so the unguarded reset is the one under test. Also asserts the prose is delivered before the commentary that interrupted it.test_no_edit_sentinel_does_not_double_send— adapter accepts the send but returns nomessage_id, driving_message_id = "__no_edit__". Asserts the segment is delivered exactly once, pinning the sentinel exclusion so this change cannot regress into double-sending.Regression guard, both directions verified. Reverting only the production hunks (tests untouched) turns 1 and 2 red:
The prose is entirely absent from what reached the user. Restoring the production change makes all three pass. Test 3 passes in both states by design — it pins existing behavior rather than demonstrating the bug.
Adjacent suites, all green with the change applied:
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the focused + adjacent gateway suites listed above instead of the full treeDocumentation & Housekeeping
docs/, docstrings) — docstrings on the new helper and the updated#8124comment blockRelated / Positioning
#8116 (@chinadbo, "preserve accumulated text on chunk send failure") is the closest open PR by concern. Its hunk is
@@ -343,13in the_send_new_chunkchunk-splitting path, i.e. a different failure site; this PR covers the segment-reset path and both of its call sites, so the two are disjoint and this is the broader of the two. Its test file (tests/gateway/test_stream_consumer_text_loss.py) is deliberately not the path used here, so there is no filename collision on merge.Other open PRs touching
gateway/stream_consumer.pywere hunk-checked and are disjoint from1112-1160: #80823 (@@1078,@@2239), #79592 (@@261,@@362,@@812,@@1164), #80173 (@@971,@@1433), #10194 (leading-newline stripping), #71725 (gateway/run.pyonly).