Conversation
The debounce flush handed every burst to merge_pending_message_event with merge_text=True, which newline-joins onto whatever already occupies the single pending slot. That merge has no time bound, so all follow-ups sent during a long turn collapsed into one turn and the model lost the message boundaries it needs to answer each question separately. Users see answers paired to the wrong question, or several questions answered as one blob. Route the flushed burst through the runner's _queue_or_replace_pending_event instead. That is the same FIFO entry point interrupt mode, steer-fallback and /queue already use, so each follow-up now gets its own turn in arrival order. Merging inside the debounce window is preserved on purpose: a single thought split across two quick taps should stay one turn. The runner is reached through the bound _busy_session_handler it already installs on the adapter, so no wiring changes are needed in run.py. Adapters with no runner attached (TUI, standalone, tests) fall back to the previous merge, which keeps the no-message-loss guarantee from NousResearch#28503 intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_queue_or_replace_pending_event returns early without queueing when it cannot resolve an adapter for the event source. Delegating into that path unconditionally would DROP the burst where the previous merge would have kept it. Guard on the runner resolving the source back to this adapter and fall back to the merge otherwise: merging is lossy, dropping is worse.
Give the stub runner an _adapter_for_source (the real runner has one) and add a regression test asserting an unresolvable source falls back to the merge instead of being dropped.
_queue_or_replace_pending_event returns WITHOUT queueing and WITHOUT raising when the source resolves to no adapter or the per-session pending cap is reached. Treating that as success dropped the burst, where the historical merge would still have delivered it. The cap was also effectively unreachable before, since the old merge collapsed every follow-up into one slot instead of one entry each. Confirm the queue actually grew and fall back to the merge when it did not.
…wning adapters Two cases the depth check alone got wrong. A media occupant in the pending slot is caption-merged by _queue_or_replace_pending_event WITHOUT growing the queue, which read as a decline and merged a second time. And a source that resolves to a different adapter owns a different pending slot, so the burst would land where this adapter drain never looks. Both now take the historical merge.
The flush now confirms the queue actually grew before treating the FIFO handoff as success, so a stub without _queue_depth never exercises the FIFO path at all and the suite silently stops proving the fix.
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.
What does this PR do?
With
display.busy_input_mode: queue, every TEXT message sent while the agent is busy is newline-joined into a single pending event and answered as one turn. Message boundaries are destroyed, so the model receives an unlabelled blob and pairs answers to the wrong questions.The path:
run.py::_handle_active_session_busy_messagedeclines plain TEXT in queue mode and returnsFalse:_flush_text_debounce_nowpushes the burst into the single pending slot viamerge_pending_message_event(..., merge_text=True), which does:That merge has no time bound. It applies to a message arriving 30 seconds later exactly as to one arriving 0.3 seconds later, so an entire conversation's worth of follow-ups collapses into one turn. The drain then pops one event and runs it as a single turn.
This is already recognised in-tree. The comment at the FIFO site in
run.pydescribes the raw merge as destroying message boundaries "so two separate user messages sent while the agent was busy... arrived as one mashed-together turn", and routes through_enqueue_fifoto fix it. That fix reached interrupt mode, steer-fallback and/queue. The queue-mode text path returnsFalsebefore ever getting there.The fix: hand the flushed burst to the runner's
_queue_or_replace_pending_event— the same FIFO entry point those other paths use — so each follow-up gets its own turn in arrival order.Merging inside the debounce window (0.35s rolling / 1.0s hard cap) is deliberately preserved. A single thought split across two quick taps should stay one turn; that is what the debounce is for.
The runner is reached through the bound
_busy_session_handlerit already installs on the adapter, so this needs no wiring changes inrun.pyand stays a single-file change. Adapters with no runner attached (TUI, standalone, tests) fall back to the previous merge, keeping #28503's no-message-loss guarantee intact.Related Issue
No exact issue exists, so I did not use
Fixes #.merge_text=Trueon the debounce path to stop the data loss. This PR completes that work for the one path it did not reach: no messages were being lost after Bug: busy_input_mode: queue silently drops messages — single-slot overwrite instead of FIFO #28503, but their boundaries were.Type of Change
Changes Made
gateway/platforms/base.py—_flush_text_debounce_nownow routes the flushed burst to the runner's FIFO, falling back to the existing merge when no runner is attached or the enqueue raises.tests/gateway/test_debounce_flush_fifo.py— new, 6 tests.How to Test
Reproduction:
display.busy_input_mode: queueinconfig.yaml.Automated:
The two behavioural tests fail on
mainand pass with the fix:The other two tests pin the fallback (no runner attached, and a raising FIFO) and pass either way by design.
Wider regression check across the related gateway suites —
test_active_session_text_merge,test_queue_command,test_queue_consumption,test_pending_drain_no_recursion,test_pending_drain_race,test_pending_event_none,test_busy_session_ack,test_discord_pending_text_batch_shutdown,test_internal_event_never_interrupts_busy_session— 39 passed.Checklist
Code
Documentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/A, no new keysCONTRIBUTING.mdorAGENTS.md— N/ANotes for the reviewer
Two things worth a look:
The 32-message cap now applies here. Text follow-ups that previously merged now occupy FIFO slots, so
_BUSY_QUEUE_MAX_PENDING(32) can be reached where it could not before. Far beyond any realistic conversation, but it drops with alogger.warningand nothing user-visible. Happy to add a user-facing notice if you'd like it in scope.The fallback is deliberate but worth confirming. The FIFO lives on the runner, so an adapter with no runner has nothing to enqueue into. Falling back to the merge is the only option there that preserves #28503's guarantee. It does mean the existing tests in
test_active_session_text_merge.pypass unchanged — they use_make_adapter(), which sets_busy_session_handler = None. That is why the new test file attaches a runner explicitly.This was found and validated on a live deployment running v0.19.0 (2026.7.20) before being reproduced against
main.Relation to other open PRs
Happy to rebase or fold this in if maintainers would rather land it alongside either.
Fallback hardening
_queue_or_replace_pending_eventcan decline silently — it returns without queueing and without raising when the source resolves to no adapter, or when_BUSY_QUEUE_MAX_PENDING(32) is reached. Treating the call as success would drop the burst, and the cap was effectively unreachable before this change since the old merge collapsed every follow-up into one slot rather than one entry each.The flush therefore confirms the queue actually grew, and falls back to the historical merge when it did not. Two cases take the historical path directly:
_queue_or_replace_pending_eventcaption-merges into it and does not grow the queue, which the depth check would misread as a decline and merge a second time.Merging is lossy; dropping is worse. Covered by
test_merge_preserved_when_fifo_declines_silently,test_merge_preserved_when_adapter_unresolvable,test_enqueue_failure_falls_back_to_mergeandtest_merge_preserved_when_no_runner_attached.