Skip to content

feat(desktop): agent-side TurnQueue - #65205

Draft
ethernet8023 wants to merge 3 commits into
mainfrom
ethie/queued-continue-desktop
Draft

ethernet8023 wants to merge 3 commits into
mainfrom
ethie/queued-continue-desktop

Conversation

@ethernet8023

@ethernet8023 ethernet8023 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

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 React useEffect that drained on the busy → false edge. 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 TurnQueue on AIAgent (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 via queue.updated events. Queued messages now fire with the tab closed, another session focused, or the window minimized.

Bonus honesty fixes along the way:

  • Steers used to paint a 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). New steer.applied/steer.dropped events 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.
  • A /steer landing after the final assistant message (pending_steer in the run_conversation result) 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

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

Backend

  • agent/turn_queue.py (new): TurnQueue — thread-safe FIFO on the agent. Entries carry source ("queue" panel-queued vs "busy_submit" mid-turn prompt) so drain events tell clients whether the text was already echoed.
  • agent/agent_init.py: wires agent.turn_queue + the _on_steer_event observer.
  • run_agent.py: _emit_steer_event(); interrupt() reports the discarded steer as dropped.
  • agent/conversation_loop.py, agent/agent_runtime_helpers.py: emit applied at the two real steer injection sites.
  • tui_gateway/server.py: _enqueue_prompt/_drain_queued_prompt delegate to agent.turn_queue (legacy session-dict slot kept as agent-less fallback); new session.queue.add/list/remove/clear/promote/update RPCs; queue.updated + queue.drained events; queue in session.info; idle enqueue drains immediately; session.interrupt clears the queue (keep_queue opts out for promote+interrupt "send now"); leftover pending_steer re-queued instead of dropped.

Desktop

  • src/store/composer-queue.ts: rewritten from localStorage-owned to gateway-backed mirror (optimistic updates settled by queue.updated); the stale localStorage key from the client-owned era is simply ignored (queued drafts were ephemeral state — not worth migration infra); new $pendingSteersBySession store.
  • 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: new queuePromptText — resolves attachments to @file: refs at enqueue time so queued text is self-contained when the gateway drains it later; steerPrompt tracks pending instead of painting the transcript.
  • src/app/chat/session-tile-actions.ts: same steer fix for tiles + tile queuePromptText.
  • src/app/session/hooks/use-message-stream/gateway-event.ts: handles queue.updated / queue.drained / steer.applied / steer.dropped + queue from session.info.
  • src/app/chat/composer/queue-panel.tsx: renders pending steers ("Steering — lands at the next tool step").
  • Dead code removed: fromQueue submit 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

  1. In the desktop app, start a long-running turn (e.g. ask the agent to run a slow command), type a follow-up, and queue it.
  2. Switch to a different session tab (or minimize the window) before the turn finishes.
  3. The queued message fires as the next turn the moment the first one ends — previously it sat in the queue until you returned to the tab. The drained text appears as a user message in the transcript.
  4. Steer mid-turn (Cmd/Ctrl+Enter): the nudge shows as pending in the queue panel, then lands in the transcript when the agent's next tool step actually ingests it. Stopping the turn before that removes the pending steer without a transcript row.

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 — clean
  • cd apps/desktop && npx vitest run --environment jsdom — 204 files, 1712 passed / 1 skipped
  • eslint + prettier clean on all touched desktop files

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: NixOS (Linux 7.1.0)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstrings on all new backend surface; no user-facing docs cover the queue internals
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no config changes)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure Python threading + TS, no platform-specific primitives
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (no model-tool changes; zero core-tool footprint — TurnQueue is framework-internal, RPCs are gateway methods)

Screenshots / Logs

=== Summary: 420 files, 8160 tests passed, 0 failed (100% complete) in 251.0s (32 workers) ===

 Test Files  204 passed (204)
      Tests  1712 passed | 1 skipped (1713)

… 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.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state needs-decision Awaiting maintainer decision before any implementation labels Jul 15, 2026
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.
@ethernet8023 ethernet8023 changed the title feat(desktop): agent-side TurnQueue — queued messages fire without the tab open feat(desktop): agent-side TurnQueue Jul 15, 2026
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.
@ethernet8023

Copy link
Copy Markdown
Collaborator Author

Pushed 8b1c1dfa1 addressing all four review findings:

  1. idle send-now no-opsession.queue.promote now drains immediately when the session is idle (same pattern as idle session.queue.add), so the "send now" gesture and the drainNextQueued rescue path actually fire.
  2. lost entry on failed dispatch_drain_queued_prompt requeues the popped entry at the head via the new TurnQueue.requeue_front() when _run_prompt_submit raises, and queue.drained only emits after a successful dispatch (no phantom user turns in the transcript).
  3. stuck multi-line steerssettlePendingSteer matches by whole-entry containment instead of line-sets, plus a message.complete backstop sweep of pending steers.
  4. speculative surface removedQueuedTurn.mode, QueuedTurn.attachments, enqueue_front(), and the dead keep_queue param are gone. Also: unused sessionId arg dropped, steer lambda un-shadowed, rejected session.queue.add surfaces an i18n'd toast.

New tests: idle-promote drains, failed-dispatch requeue (with no queue.drained), interrupt clears queue, multi-line steer settle. scripts/run_tests.sh tests/tui_gateway/test_turn_queue.py → 16 passed; desktop tsc/eslint/vitest clean (one unrelated skills/index.test.tsx full-suite timing flake, passes in isolation on both this branch and base).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants