Skip to content

Add "Send now" steering for queued follow-up messages#162

Open
Zeus-Deus wants to merge 2 commits into
mainfrom
fix/queued-messages
Open

Add "Send now" steering for queued follow-up messages#162
Zeus-Deus wants to merge 2 commits into
mainfrom
fix/queued-messages

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Summary

Adds a "Send now" action to queued follow-up messages, so a user can steer a running agent without waiting for the whole turn to finish — and without losing anything.

Previously, a message sent while a turn was in flight queued behind it and only dispatched when the turn fully completed. That wastes tokens on a stale plan when the user's follow-up was meant to redirect the work. This mirrors Claude Code's documented interrupt-and-resubmit steering path (true inject-into-live-turn steering isn't exposed by the Agent SDK yet — see docs update).

Nothing is discarded: the interrupt is a soft stop — same SDK session, full transcript preserved, all on-disk work from the turn (including subagents) kept. The queued message simply dispatches immediately as a normal follow-up turn, in real turn order.

How it works

  • send_queued_now() on the Claude and Codex sessions promotes the queued item to the front of the follow-up queue, then reuses the existing interrupt + drain_queue machinery:
    • Claude: interrupt already flips the session to Ready and drains inline — the promoted item goes out next.
    • Codex: turn/interrupt aborts; the event loop drains when the session returns to Ready. A "no active turn" race falls back to draining directly.
  • Idempotent semantics matching cancel_queued: an unknown / already-dispatched id is a silent no-op.
  • Pending approvals behave exactly like a manual stop-button interrupt: an outstanding approval holds the dispatch until resolved (no new approval-clearing behavior invented).
  • New agent_chat_send_queued_turn_now command mirrors agent_chat_cancel_queued_turn; the provider trait default is a no-op for queue-less providers (OpenCode).
  • No reducer or persistence changes needed — the existing QueuedTurnDispatched event promotes the greyed bubble and writes the deferred user-message envelope (with any stashed images) in real turn order.
  • UI: hover-reveal "Send now" (↵) button beside Cancel on the queued bubble, theme tokens only.
  • Dev mock: soft-interrupt support so the whole flow is demoable in npm run dev.

Verification

  • cargo check / cargo test — pass (6 new unit tests for the queue-promotion helper)
  • npm run check / npm run test — pass (one pre-existing flaky teardown failure in new-workspace-dialog.test.tsx, passes in isolation)
  • Verified end-to-end in the browser against the dev mock: queued a steer message mid-turn, clicked "Send now" — queued pill cleared, the steer dispatched as its own follow-up turn, and the interrupted turn's partial output remained in the transcript.

Docs

docs/features/agent-chat.md § Follow-up queueing gains a "Send now (steer)" subsection; the steering TODO is updated: interrupt-based send-now is done, inject-into-live-turn (streamInput) remains out of scope pending Agent SDK support.

🤖 Generated with Claude Code

A follow-up sent while a turn is running queues behind it and only
dispatched when the turn fully finished — wasting work on a stale plan
when the user wanted to steer. The queued bubble now offers a "Send now"
action that promotes the message to the front of the queue and
soft-interrupts the active turn: the session, transcript, and all
on-disk work are preserved, and the message dispatches immediately as a
normal follow-up turn so the agent re-plans with the steer.

- Claude/Codex sessions: send_queued_now() promotes the queued item and
  reuses the existing interrupt + drain_queue machinery (Claude drains
  inline after interrupt; Codex drains via the event loop when the abort
  returns the session to Ready, with a race fallback)
- New agent_chat_send_queued_turn_now command mirroring the cancel
  command; provider trait default is a no-op for queue-less providers
- UI: hover-reveal "Send now" button beside Cancel on the queued bubble;
  the existing queued_turn_dispatched event promotes the bubble and
  persists the envelope in real turn order
- Dev mock: soft-interrupt support so the flow is demoable in npm run dev
- Docs: agent-chat.md follow-up queueing section updated; true
  inject-into-live-turn steering stays out of scope pending SDK support
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

The Codex half of this is sound (turn/interrupt genuinely aborts only the turn; turn/completed(status: "interrupted")Ready → event-loop drain, codex/session.rs:997-1024), OpenCode is correctly a no-op (it never queues), and the frontend threading with no optimistic state is clean. But the Claude path has a blocking problem:

Claude "Send now" dispatches into (or is cancelled by) a dead SDK query — the "soft stop" premise doesn't hold for Claude.

send_queued_now (src-tauri/src/agent_provider/claude/session.rs:503-522) promotes the item and calls interrupt(None), relying on the interrupt flipping to Ready and draining inline so the promoted item goes out next. But per this branch's own code, a Claude interrupt is not a soft stop:

  • sidecar/claude-agent/src/session.ts:426-431 — interrupt() doc: the SDK emits no final result; the message-iteration loop surfaces session-ended with reason: "interrupted" — i.e. the SDK query's iterator exits after query.interrupt().
  • src/components/chat/AgentChatPane.tsx:1607-1618 — the Stop button's comment states the session "is functionally dead after that" and deliberately does interrupt → stop_sessionstart_session (with resume cursor) precisely because "subsequent turns land on a live SDK query". send_queued_now performs no such rebuild.
  • src-tauri/src/agent_provider/claude/translate.rs:263-287 — session-ended("interrupted") translates to TurnCompleted(Error) + SessionStateChanged(Closed).
  • src-tauri/src/agent_provider/claude/session.rs:1304-1312 — mutate_state_from_notification sets status = Closed for that reason; session.rs:933-942 — the notifications task then runs cancel_all_queued().

Concrete failure, in the feature's primary case (a turn is running):

  • If session-ended("interrupted") is processed before interrupt's inline drain_queue, the drain's idle check (session.rs:401-410, requires Ready) refuses, and cancel_all_queued deletes the promoted message — the bubble vanishes, the message is never sent, and (unlike an explicit cancel) the text is not restored to the composer. Silent message loss.
  • If the inline drain wins the race, do_send pushes the turn into the exited query's prompt queue (the sidecar's sendTurn only checks this.closed, session.ts:376-378, which a bare interrupt never sets), emits QueuedTurnDispatched, persists the user-message envelope, and flips state to Running — then Closed lands. The user sees their message "sent", no reply ever comes, and the chat is wedged on a dead query until a manual Stop or restart.

The end-to-end verification was against the dev mock, whose interruptMockChatTurn implements exactly the soft-interrupt semantics the real Claude sidecar does not have.

Fix options: give the Claude path the Stop button's interrupt → stop → start(resume cursor) → drain sequence (preserving the queue across the rebuild); or rebuild the session at the provider level before draining; or, if the currently-bundled SDK actually keeps the query alive across interrupt(), demonstrate that and reconcile the three in-repo comments plus the session-endedClosedcancel_all_queued pipeline first. The new docs subsection in docs/features/agent-chat.md ("Claude: interrupt(None) flips the session to Ready… the promoted item dispatches inline") should be updated along with whichever fix lands.

Non-blocking, worth a look while in here:

  • codex/session.rs:556-565 — when interrupt_turn fails with a non-ValidationError, the error is surfaced but the queue stays permanently reordered (the item remains promoted), so the "Failed to send queued message" toast understates what happened.
  • Pending-approval wedge (pre-existing shape, now easier to reach): after send-now interrupts a turn with an outstanding approval, drain_queue blocks on pending_approvals, and nothing re-drains when the approval resolves — respond_to_request (claude/session.rs:677) never calls drain_queue. Meanwhile new composer sends bypass the queue (enqueue_or_send checks only active_turn), so the promoted message can dispatch after a newer message or sit greyed indefinitely. A drain_queue after a successful respond_to_request would close it.
  • Pre-existing concurrent-drain race, made more reachable: drain_queue's pop-then-do_send isn't atomic w.r.t. active_turn (claude/session.rs:397-449), so the inline drain at the end of interrupt can race the notifications-task drain and dispatch two different queued items nearly simultaneously when two or more are queued.

…atches onto a live query

The "Send now" path assumed a Claude interrupt was a soft stop, but
query.interrupt() kills the SDK query: the sidecar surfaced
session-ended(interrupted), the session went Closed, and the promoted
message was either cancelled by cancel_all_queued or pushed into a
prompt queue nothing consumes. Fix the root cause instead of patching
around it:

Sidecar:
- interrupt() now awaits the SDK iterator's exit, suppresses the bogus
  session-ended, marks the query dead, and emits a new turn-interrupted
  notification; spontaneous aborts keep the old behavior.
- The next send-turn transparently rebuilds a resumed query (fresh
  prompt queue, resume = last observed sdk session id, re-emitting
  sdk-session-id so the host cursor stays current). setModel /
  setPermissionMode / updateMcpTools are recorded so rebuilds keep
  fidelity.
- Aborting a turn with a pending tool approval now emits
  request-resolved (deny) instead of nothing.

Rust:
- Translate turn-interrupted to TurnCompleted(interrupted) +
  SessionStateChanged(Ready) — session survives, queue preserved.
- Clear pending_approvals on request-resolved notifications and drain
  after respond_to_request, so an approval outstanding at interrupt
  time can no longer wedge the follow-up queue.
- interrupt() only resets state when the interrupted turn is still the
  active one, so it can't clobber a turn the notifications task already
  dispatched.
- drain_queue gained a dispatching guard closing the pop-then-send race
  that could double-dispatch when two drains overlap.
- send_queued_now (Claude and Codex) restores the queued item's
  original position when the interrupt RPC fails instead of leaving the
  queue silently reordered.

Docs and stale comments updated to the new semantics. Verified with
cargo test (1996 pass), sidecar bun test (75 pass, incl. new rebuild /
turn-interrupted / abort-resolution coverage), tsc, and vitest (2477
pass).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant