Add "Send now" steering for queued follow-up messages#162
Conversation
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
|
The Codex half of this is sound ( Claude "Send now" dispatches into (or is cancelled by) a dead SDK query — the "soft stop" premise doesn't hold for Claude.
Concrete failure, in the feature's primary case (a turn is running):
The end-to-end verification was against the dev mock, whose 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 Non-blocking, worth a look while in here:
|
…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).
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_queuemachinery:interruptalready flips the session toReadyand drains inline — the promoted item goes out next.turn/interruptaborts; the event loop drains when the session returns toReady. A "no active turn" race falls back to draining directly.cancel_queued: an unknown / already-dispatched id is a silent no-op.agent_chat_send_queued_turn_nowcommand mirrorsagent_chat_cancel_queued_turn; the provider trait default is a no-op for queue-less providers (OpenCode).QueuedTurnDispatchedevent promotes the greyed bubble and writes the deferred user-message envelope (with any stashed images) in real turn order.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 innew-workspace-dialog.test.tsx, passes in isolation)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