feat(desktop): agent-side TurnQueue - #65205
Draft
ethernet8023 wants to merge 3 commits into
Draft
ethernet8023 wants to merge 3 commits into
ethernet8023 wants to merge 3 commits into
Conversation
… messages fire without the tab open
Queued messages in the desktop app lived in localStorage and were drained
by a React useEffect on the busy->false edge — if the session tab wasn't
mounted, the effect never ran and queued prompts sat dormant forever.
Move the queue into the agent process where steer already lives:
- agent/turn_queue.py: TurnQueue, a thread-safe FIFO on AIAgent (wired in
agent_init next to _pending_steer). Entries carry a `source` field
("queue" vs "busy_submit") so drain events tell clients whether the
text was already echoed optimistically.
- tui_gateway: _enqueue_prompt/_drain_queued_prompt delegate to
agent.turn_queue; new session.queue.add/list/remove/clear/promote/update
RPCs; queue.updated + queue.drained events; queue in session.info.
An idle-session enqueue drains immediately — the gateway owns every
drain path. session.interrupt clears the queue (keep_queue opts out
for the promote+interrupt "send now" gesture). A leftover pending_steer
returned by run_conversation is re-queued as the next turn instead of
being silently dropped.
- steer honesty: new agent._on_steer_event observer fires steer.applied
at the two real injection sites (pre-API drain + tool-batch drain) and
steer.dropped when an interrupt discards the pending steer. The desktop
shows steers as pending in the queue panel and only appends the steer:
transcript row when the model actually saw the text (both the primary
composer and session tiles previously painted it at RPC-accept time).
- desktop: composer-queue.ts rewritten as a gateway-backed mirror
(optimistic updates settled by queue.updated); the auto-drain effect is
deleted; "send now" promotes on the gateway; attachments resolve to
@file: refs at enqueue time so queued text is self-contained when the
gateway drains it later; one-time localStorage migration. Dead code
removed: fromQueue submit option, shouldAutoDrain, queueStuck i18n.
Tests: tests/tui_gateway/test_turn_queue.py (TurnQueue unit + RPC
integration + drain semantics), composer-queue.test.ts rewritten for the
gateway-backed store.
The old client-owned queue was ephemeral draft state; losing a queued message across the one upgrade isn't worth carrying migration code forever. The stale localStorage key is simply ignored.
Review findings on the TurnQueue PR, fixed in one pass: 1. "Send now" on an idle session was a silent no-op: session.queue.promote reordered the queue but never drained it, so the promoted entry (and the drainNextQueued rescue gesture built on it) just sat there. Promote now fires _drain_queued_prompt in a thread when the session is idle, same as an idle session.queue.add. 2. A drained entry whose dispatch raised was lost: _drain_queued_prompt popped the entry and emitted queue.drained (painting a user turn in the client transcript) before _run_prompt_submit. On exception the entry was gone and the transcript lied. The drain now requeues the entry at the head (same id, so client mirrors stay consistent) via the new TurnQueue.requeue_front(), and queue.drained is only emitted after a successful dispatch. 3. Multi-line steers never settled: settlePendingSteer split the applied text into a line-set, so an entry that itself contained newlines (Cmd+Enter on a multi-line draft) matched nothing and pinned a "Steering..." row forever. Now matches by whole-entry containment, plus a message.complete backstop sweep (a steer can't outlive its turn: applied, dropped, or re-queued as the next turn). 4. Speculative surface removed per the contribution rubric: QueuedTurn.mode (written, never read), QueuedTurn.attachments (clients resolve attachments to @file: refs at enqueue time), enqueue_front() (replaced by the requeue_front() that finding 2 actually needs), and the keep_queue param on session.interrupt (documented for a promote+interrupt flow that actually interrupts via agent.interrupt() directly, so it was dead). Also: unused sessionId arg dropped from useComposerQueue, the steer-event lambda no longer shadows the enclosing text parameter, and a rejected session.queue.add now surfaces an i18n'd error toast instead of silently no-oping (draft is kept either way). Tests: idle-promote drains immediately, failed dispatch requeues at head without emitting queue.drained, interrupt clears the queue, multi-line steer settles. 16 gateway tests pass; desktop tsc/eslint/vitest clean.
Collaborator
Author
|
Pushed
New tests: idle-promote drains, failed-dispatch requeue (with no |
ethernet8023
marked this pull request as draft
July 15, 2026 21:56
19 tasks
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.
What does this PR do?
Fixes the desktop bug where queued messages sometimes don't fire until you switch back to their session tab, by moving the turn queue out of the client and into the agent process — unifying queue/steer handling across desktop/TUI/CLI surfaces.
Root cause: the desktop queue lived in
localStorage+ a ReactuseEffectthat drained on thebusy → falseedge. If the session tab wasn't mounted, the effect never ran and queued prompts sat dormant forever. Steer was already agent-side (AIAgent.steer()); queue now joins it there.The fix: a
TurnQueueonAIAgent(next to_pending_steer), drained by the gateway at the end of every turn and immediately on idle enqueue. The gateway owns every drain path; the desktop is a thin view that mirrors state viaqueue.updatedevents. Queued messages now fire with the tab closed, another session focused, or the window minimized.Bonus honesty fixes along the way:
steer:transcript row the instant the RPC returned — but the text only reaches the model at the next tool-batch boundary (and an interrupt can drop it entirely). Newsteer.applied/steer.droppedevents fire from the actual injection sites; the desktop shows steers as pending in the queue panel and only writes the transcript row when the model really saw the nudge./steerlanding after the final assistant message (pending_steerin therun_conversationresult) was silently dropped by the tui_gateway — it's now re-queued as the next turn.Related Issue
Fixes the "queued messages don't send unless the session window is open" desktop behavior (reported internally; no tracked issue).
Type of Change
Changes Made
Backend
agent/turn_queue.py(new):TurnQueue— thread-safe FIFO on the agent. Entries carrysource("queue"panel-queued vs"busy_submit"mid-turn prompt) so drain events tell clients whether the text was already echoed.agent/agent_init.py: wiresagent.turn_queue+ the_on_steer_eventobserver.run_agent.py:_emit_steer_event();interrupt()reports the discarded steer asdropped.agent/conversation_loop.py,agent/agent_runtime_helpers.py: emitappliedat the two real steer injection sites.tui_gateway/server.py:_enqueue_prompt/_drain_queued_promptdelegate toagent.turn_queue(legacy session-dict slot kept as agent-less fallback); newsession.queue.add/list/remove/clear/promote/updateRPCs;queue.updated+queue.drainedevents;queueinsession.info; idle enqueue drains immediately;session.interruptclears the queue (keep_queueopts out for promote+interrupt "send now"); leftoverpending_steerre-queued instead of dropped.Desktop
src/store/composer-queue.ts: rewritten from localStorage-owned to gateway-backed mirror (optimistic updates settled byqueue.updated); the stale localStorage key from the client-owned era is simply ignored (queued drafts were ephemeral state — not worth migration infra); new$pendingSteersBySessionstore.src/app/chat/composer/hooks/use-composer-queue.ts: auto-drain effect deleted (the bug); "send now" =session.queue.promote {interrupt}; queue-edit UX preserved.src/app/session/hooks/use-prompt-actions/index.ts: newqueuePromptText— resolves attachments to@file:refs at enqueue time so queued text is self-contained when the gateway drains it later;steerPrompttracks pending instead of painting the transcript.src/app/chat/session-tile-actions.ts: same steer fix for tiles + tilequeuePromptText.src/app/session/hooks/use-message-stream/gateway-event.ts: handlesqueue.updated/queue.drained/steer.applied/steer.dropped+queuefromsession.info.src/app/chat/composer/queue-panel.tsx: renders pending steers ("Steering — lands at the next tool step").fromQueuesubmit option (nothing sets it now),shouldAutoDrain,MAX_AUTO_DRAIN_ATTEMPTS,queueStuck*i18n keys (all 4 locales).Tests
tests/tui_gateway/test_turn_queue.py(new): TurnQueue unit behavior (FIFO, promote, thread-safety under concurrent enqueue/drain) + RPC integration (add/list/remove/clear/promote/update, idle immediate drain, busy-submit source tagging, drain event payloads).src/store/composer-queue.test.ts: rewritten for the gateway-backed store.How to Test
Verification run:
scripts/run_tests.sh tests/tui_gateway/ tests/run_agent/ tests/agent/— 8160 tests, 0 failed (incl. 13 new turn-queue tests)cd apps/desktop && npx tsc -p . --noEmit— cleancd apps/desktop && npx vitest run --environment jsdom— 204 files, 1712 passed / 1 skippedChecklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — docstrings on all new backend surface; no user-facing docs cover the queue internalscli-config.yaml.exampleif I added/changed config keys — N/A (no config changes)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs