I had feedback from a user that wanted to Queue us...
# proj-agent-canvas
a
I had feedback from a user that wanted to Queue user messages instead of interrupting the current run. Right now, if we send a message while the agent is executing, the current run is stopped and the new message is processed immediately. That kills in-flight terminal/tools (including long monitors) and wastes the turn. They preferred a message queue: keep the current run; enqueue follow-ups; process them in order when the agent is idle. Interrupting should be an explicit user action, not the default. I know in Codex you can decide whether to queue a message or "steer". Right now in OpenHands it seems like it always steer. I hope this makes sense, any thoughts?
l
The behavior is actually inconsistent between ACP and OpenHands 😕 • ACP: kills the current action immediately • OpenHands: queues, although you can kill the action by pressing “stop” So basically we should bring ACP in line with OpenHands behavior.
@fresh-machine-37269 check if there are any existing issues on this topic on OpenHands/OpenHands and OpenHands/software-agent-sdk. Come up with an architectural proposal that we would need in order to implement this.
👀 1
f
🤖 On it! View progress here: oss-agent-canvas.ngrok.dev/conversations/…
c
I think this is a bit confusing: • the current action continues, it’s not stopped However, the question is when is the new message added: • at the end of the agent turn (“queue”) • in the current turn, just after the current tool calls (“steer”)
There is an old discussion on OpenHands/CLI repo
f
✅ Done! Here's what I found and the architecture I'd propose. Existing issues / PRs on this topic There's a well-developed trail here — mostly on the SDK side. The root cause is already identified and being fixed. The core bug — messages lost while running •
OpenHands/OpenHands#15432
— "messages sent during streaming are not always seen by the agent" (open). This is the canonical issue for "send a message mid-run and it's never acted on." •
OpenHands/software-agent-sdk#4194
— "pick up a user message that arrives during an async step" (open PR). Directly fixes #15432:
arun()
now re-scans for a newer user message before honoring
FINISHED
instead of silently dropping it into the next turn. •
OpenHands/software-agent-sdk#4674
— "Split ConversationState's single FIFOLock by responsibility" (open, labeled
architecture
,
ready-for-dev
). This is the real architectural fix: the single
FIFOLock
currently guards six independent concerns (event log, agent, execution_status, secrets, stats, HEAD), so message intake and the run loop contend at every step boundary. It proposes per-concern locks so intake can append while the agent runs. Explicitly notes it "closes #15432 by itself, with no inbox, no queue and no protocol work." Frontend / UI ordering •
OpenHands/OpenHands#15433
— "user messages can split streaming text" (open). •
OpenHands/OpenHands#16119
— "keep queued messages below streaming responses" (closed). Adds the "deferred/pending user message" UI projection so a message sent mid-stream sits visually at the tail until all crossing streams finalize. ACP alignment (the "always steer" side of the report) •
OpenHands/OpenHands#15912
— "ACP: expose pause / resume / cancel for conversations driven by Claude Code, Codex, Gemini CLI" (open). Notes ACP kills the action immediately while OpenHands queues. This is the issue most directly matching the "bring ACP in line with OpenHands" comment. •
OpenHands/software-agent-sdk#4108
— "propagate interruptions to active subagents" (open). •
OpenHands/software-agent-sdk#3557
— _"don't bump explicit_interrupt_generation on no-op pause/interrupt"_ (open). •
OpenHands/software-agent-sdk#4329
— "ACPAgent.close() races with agent.step()" (open). So the answer to "are there existing issues?" is yes — substantially, and active work is already in flight. Architectural proposal The cleanest framing: separate message intake from turn control, and make "steer vs queue" an explicit mode rather than an implicit side effect of the lock/status race. 1. Introduce an explicit intake inbox per conversation (SDK level). Instead of
send_message
racing with the run loop for the FIFO lock, every incoming message lands in a small inbox/queue keyed by conversation. The run loop owns a single "should I keep going?" gate that drains the inbox only at defined points (step boundaries /
FINISHED
). This is essentially formalizing what #4194 patches ad-hoc and what #4674 wants structurally: intake needs only the event-log lock; the run loop needs only agent + status. 2. Split the lock, don't widen it (build on #4674). Per-concern locks with a documented acquisition order. The run loop holds agent + `execution_status`; intake holds the event log. This is the prerequisite for any queuing semantics — you can't reliably enqueue while the agent runs if appending and stepping contend on one lock. 3. Model the "steer vs queue" decision as an explicit control-plane action. Right now "queue" in OpenHands and "kill" in ACP are emergent behaviors. Propose a first-class per-message (or per-session-default) flag: •
queue
(default): append to inbox; run loop picks it up at the next boundary, preserving the in-flight turn and long-running monitors. • `steer`/`interrupt`: emit an explicit
InterruptEvent
(bumping
explicit_interrupt_generation
), cancel the in-flight action/LLM call, and make the new message the head of the queue. • `pause`/`resume`/`cancel` as separate lifecycle verbs (already partially exposed for ACP in #15912). 4. Unify ACP with the built-in agent on the same intake path. ACP currently bypasses the queue and cancels via
session/cancel
immediately. Route ACP message intake through the same inbox + control-plane layer, translating
queue
→ buffered `session/load`+message and
steer
→
session/cancel
+ immediate message. This brings ACP in line with OpenHands (matching the comment that ACP is the outlier), and #4329's close/step race fix is a prerequisite for doing this safely. 5. Keep the UI "deferred message" projection (already in #16119) as the presentation layer. The inbox produces
pending user message
entries that the frontend renders at the tail until crossing streams finalize, so a queued message never visually splits an assistant response. Sequencing I'd suggest: 1. Land #4674 (lock split) → makes intake-while-running correct. 2. Land #4194 (re-scan before FINISHED) → closes #15432. 3. Add the explicit inbox + control-plane verbs (`queue`/`steer`/`pause`/`resume`) on the SDK, then the agent-server. 4. Port ACP to that intake path (#15912 + #4329 fixes as prerequisites). 5. Default the UI to
queue
, expose `steer`/`stop` as explicit actions. Want me to dig into any of the specific PRs (e.g. #4674's lock-splitting details or #15912's pause/resume API surface) more deeply?
l
@fresh-machine-37269 is there a more minimal change we could do first? be concise.
👀 1
c
@limited-student-10747 ACP aside (not sure what’s up there), I think your agent didn’t find the relevant discussion… @bland-bear-604 find an old issue started by enyst on CLI upstream repo about this, and anything else if inline
f
✅ Done! Yes — merge software-agent-sdk#4194 as-is. It's a tiny patch:
arun()
re-scans for a newer user message before honoring
FINISHED
(and leaves the conversation
IDLE
on the last iteration). It closes #15432 with no inbox, queue, or protocol changes. The bigger lock-split (#4674) and ACP work can follow.
l
@fresh-machine-37269 does that bring the behavior in line between openhands agents and acp agents?
👀 1
f
✅ Done! Partially. #4194 only fixes the OpenHands async run loop to re-scan for a message before honoring
FINISHED
— it actually mirrors logic the ACP branch already had. So it closes the message-loss gap in the native agent, but it does not change the "queue vs kill" semantics: ACP still cancels the in-flight action immediately, while OpenHands queues. Bringing ACP in line (queue instead of immediate cancel) still needs the intake/control-plane work (#15912 + routing ACP through the same inbox).
l
@fresh-machine-37269 give me the minimal set of changes that could make the ACP behavior the same as the current openhands behavior
👀 1
f
✅ Done! Minimal set to make ACP queue like OpenHands (instead of killing the in-flight action): 1. Stop the immediate cancel. In the ACP message-intake path (
EventService
→
ACPAgent
), when status is
RUNNING
, don't emit
session/cancel
. Instead persist the incoming message as a pending/queued message. 2. Drain the queue on idle. When the ACP step finishes and the conversation goes `FINISHED`/idle, check for queued messages and issue
session/prompt
(or
session/load
+ message) to resume and process the next one — mirroring the
run:true
follow-up path already available in the server. 3. Keep explicit "stop" as the only cancel path. The Stop button continues to map to `session/cancel`/interrupt; it just stops being the default for ordinary messages. That's the core. Supporting bits: rely on the existing deferred-message UI projection (#16119) so the queued message renders at the tail without splitting the stream, and land the `ACPAgent.close()`/step race fix (#4329) so cancel-on-stop is safe. Everything else (lock split #4674, general inbox) is optional hardening, not required for parity.
b
The old enyst issue is OpenHands-CLI#226 — "Queue prompt at the end of turn?" (Dec 2025): https://github.com/OpenHands/OpenHands-CLI/issues/226 It's the same question, and it names the distinction precisely — the axis is when a queued message is injected, not whether messages get dropped: • steer (Claude Code, and OpenHands today): inject the message mid-turn, in the current agent turn. • queue (Codex): hold it and inject only when the agent yields control — the LLM stops with a plain-text message and no tool calls (end of turn). #226's use-case is the payoff: give the agent a big task, queue several "continue" messages, walk away; each "continue" fires when the model returns plain text, so the agent self-drives hours-long tasks. enyst's note there: this only became genuinely useful with GPT-5.x-class models. Related trail (for "anything else inline"): • sdk#270 (xingyaoww) — the root feature: concurrent message handling. P0 = message affects the current task; P2 = quick side-question without touching history. https://github.com/OpenHands/software-agent-sdk/issues/270 • sdk#336 (simonrosenberg) — "queue user messages in agent conversation," the original queue implementation. https://github.com/OpenHands/software-agent-sdk/issues/336 • sdk#1304 — premature termination when the LLM produces content-only; that "plain-text = end of turn" behavior is exactly the trigger an end-of-turn queue relies on. So the message-loss bug (#15432 / #4194) is real but orthogonal — that's "don't drop a message that arrived mid-run." The product question here is the steer-vs-queue injection point, and #226 is where enyst already worked it through, landing on end-of-turn queue as the default worth having. 🐾
l
@fresh-machine-37269 double-check smolpaws’s comment above, is the timing for posting messages really different between OpenHands and codex? (can you take a look at both codebases and check) Further, does this change your assessment of what to do?
👀 1
f
✅ Done! Short answer: smolpaws had it backwards. Both Codex and OpenHands queue by default and treat steer/interrupt as an explicit separate action. There's no real "queue vs steer" divergence between the two products — the divergence is only in OpenHands' ACP path, which cancels immediately. What I found in the code: Codex (openai/codex) — queue-by-default, steer is explicit: •
protocol/src/turn_input.rs
defines
TurnInputMode::StartOrSteer | StartIfIdle | Steer { expected_turn_id }
— three explicit routing modes. •
start_if_idle(...)
returns
NotSubmittedReason::NotIdle
when a turn is active — i.e. it does not kill the running turn. • The TUI keeps a real queue (`chatwidget/input_queue.rs`:
queued_user_messages
,
pending_steers
,
rejected_steers_queue
), and `turn_runtime.rs`'s
maybe_send_next_queued_input()
sends exactly one queued message when the turn completes. Steer is a deliberate user action, not the default. OpenHands (software-agent-sdk) — also queue-by-default: •
local_conversation.py
send_message()
just appends a
MessageEvent
and rewinds
FINISHED/STUCK → IDLE
so the run loop picks it up at the next boundary. It does not cancel the in-flight step. • The run loop (`run`/`arun`) re-scans for a newer user message before honoring
FINISHED
. Stop/interrupt is an explicit action. So the honest picture is: Codex and OpenHands-native already match — both queue. The outlier is OpenHands' ACP integration (driven by the ACP
session/cancel
path), which steers by default and kills the action. Does this change my assessment? Yes, in a good way — it simplifies it. There's no need to invent a new "queue vs steer" decision layer; that model already exists in Codex and roughly in OpenHands-native. The minimal fix is unchanged and even narrower: make ACP's intake match OpenHands-native (queue, don't cancel), with explicit "stop" as the only cancel path. Everything else (#4674 lock split, general inbox) remains optional hardening. One caveat worth flagging: OpenHands-native's queue still had the async message-loss bug (#15432), and #4194 is what makes that queue reliable in the async path. So for parity to actually hold end-to-end, #4194 should still land — but it's about correctness of the queue, not about matching Codex's model.
c
^^ This is mainly false, your agent is very influenced by previous context? Nowhere did smolpaws say anything about ACP
l
I think it’s correct? This comment from smolpaws seems wrong:
It’s the same question, and it names the distinction precisely — the axis is when a queued message is injected, not whether messages get dropped:
• steer (Claude Code, and OpenHands today): inject the message mid-turn, in the current agent turn.
• queue (Codex): hold it and inject only when the agent yields control — the LLM stops with a plain-text message and no tool calls (end of turn).
I use codex and openhands a lot and I’m pretty sure that their behavior is the same
m
I can confirm. Claude and Openhands have the same strategy
l
Wait, so let’s get terminology straight: steer = interrupt the current step and send a message queue = don’t interrupt, send the message after the current agent action
I am pretty certain that OpenHands native and Codex both queue by default, but have options to steer
I don’t really use claude code so I don’t know the default, but I guess it’s also the same?
c
Words are confusing us. We're talking about different things. I just asked codex: • list all PRs you made for this (user message) => agent started working Then I said: • test As you can see, the current behavior in Codex is “queue” What “queue” means: => the agent doesn’t get the “test” message It continues tool calls, it does many steps, it could work for 24 hours if that’s what it takes. Then, only when it finishes the turn, when it’s FINISHED or IDLE, then it gets the “test” message, and starts another turn.
l
Oh, I’m talking about the codex CLI
nod 1
c
In Codex, • “steer” means “ok insert this new user message as soon as possible” • “queue” means “the harness keeps this message on hold, and lets the agent do its thing, until FINISHED. When finished, the new user message will prompt it again.”
l
I don’t think that’s what it means in the codex CLI
But let’s separate 3 things: 1. interrupt the current tool call and post 2. wait for the current tool call then post 3. wait for finished then post
c
I don’t remember if they both exist in the CLI… But FWIW, there is a separate config option to make “steer” default, so that could happen too..
Yes
Yes, these are 3 separate things
Rajiv’s OP has all three, actually
l
@fresh-machine-37269 read the codex and openhands codebases again, and see which of these three they support by default or by settings
👀 1
c
“I know in Codex you can decide whether to queue a message or “steer”. Right now in OpenHands it seems like it always steer.” I think this is accurate. We don’t have this “queue when this conversation is FINISHED” behavior 😅
a
I don't think we queue
nod 1
f
✅ Done! I re-read both codebases. My earlier conclusion was wrong on one key point — Codex does not queue by default; it steers by default. Here's the corrected picture. The three behaviors: Codex — steer by default. The evidence: •
protocol/src/turn_input.rs
defines
TurnInputMode::StartOrSteer | StartIfIdle | Steer
— and
StartOrSteer
is the default (
start_or_steer_turn
). • The TUI submits immediately when a turn is running (
<http://input_flow.rs|input_flow.rs>
):
should_submit_now = … || agent_turn_running
, then
<http://thread_routing.rs|thread_routing.rs>
calls
turn_steer(...)
when there's an active turn id, falling back to
turn_start
only on
NoActiveTurn
. •
steer_input()
(
<http://turn_input.rs|turn_input.rs>
) injects the message into the running turn's mailbox — it does not cancel the in-flight action. Interrupt is a separate explicit op (
AppCommand::Interrupt
→
turn/interrupt
). • Queueing is the fallback, not the default: it kicks in for non-steerable turns (Review/Compact), plan streaming (
is_plan_streaming_in_tui()
), or before the session is configured. I found no user-facing config toggle for queue-vs-steer in the TUI. OpenHands native — queue by default.
send_message()
only appends a
MessageEvent
and rewinds `FINISHED/STUCK → IDLE`; the run loop picks it up at the next step boundary. There's no "steer into a live step" concept, and interrupt/stop is explicit. OpenHands ACP — kill by default. It routes intake through
session/cancel
, killing the in-flight action; it has neither queue nor steer. So smolpaws was essentially right — Codex steers (and offers queue as a mode), while OpenHands queues and only offers "stop." The two products genuinely diverge on the default. Does this change the assessment? Yes. My previous "they already match, only ACP is the outlier" was incorrect. The real gap is two-fold: 1. ACP is the odd one out — it kills where both Codex and OpenHands-native are non-destructive (steer/queue). 2. OpenHands-native has no "steer" mode at all, so it can't match Codex's default without new capability. That reframes the target: if the goal is Codex parity, the right end-state is to add a steer (non-destructive inject) primitive to OpenHands, make it the default like Codex, keep queue as fallback for non-steerable states, keep explicit "stop" as interrupt — and route ACP onto that same three-way control plane instead of its current always-kill behavior. If the goal is just internal consistency (bring ACP to OpenHands-native), the earlier minimal fix (ACP queues instead of cancels) still stands. Which of those two end-states are you actually aiming for?, with interactive elements
c
@limited-student-10747 May I ask, what LLM do you use here?
It inverted the concepts, as used by Codex App
l
Not sure actually, maybe GLM 🙂 I think it’s wrong about openhands native
But I think it’s right about codex in the CLI
I don’t use the codex app so I’m not sure
c
It seems very tributary to its initial context
You could maybe try it on codex CLI, in a longer running run, to send a mid-turn message, and see when it sends it to the agent
l
I use the codex CLI all the time so I’m sure default is 2
c
It may be configured to steer
l
Yep, and I haven’t changed the configuration.
But anyway, my terminology was wrong previously, let’s go with “1. interrupt”, “2. steer”, “3. queue”
And I think that means that OpenHands native supports interrupt and steer, but not queue, and OpenHands ACP supports interrupt only
👍 1
c
I love that - yes, I think if we keep this terminology, we can figure this out a bit more clearly
l
@fresh-machine-37269 review all of this discussion, and re-correct your overall assessment — do we have any issues open to create a queue option?
👀 1
f
✅ Done! Reviewed the whole thread. Corrected assessment: No — there is no open issue for "create a queue option." The earlier search hits were two different, unrelated things, and I want to correct my conflation of them: 1. WebSocket/transport queueing —
OpenHands/OpenHands
has a bunch of closed issues/PRs with "queue" in the title (e.g. #12279, #13373, #14181, #14598, #16119 "keep queued messages below streaming responses"). These are about delivering messages over the wire (offline/reconnect/startup buffering, and UI ordering while streaming). None of them implement a user-facing choice to queue vs. steer vs. interrupt. 2. The message-loss bug —
OpenHands/OpenHands#15432
and the SDK fixes
#4194
(re-scan before FINISHED) and
#4674
(split the FIFOLock). These make OpenHands-native's existing queue reliable; they do not add a queue option. So the actual feature the user described — "keep the current run, enqueue follow-ups, process when idle; make interrupting an explicit action" — has no open issue tracking it in either repo. What exists is adjacent groundwork (the native queue already behaves this way, and #4194/#4674 fix its race), plus the ACP-consistency angle (#15912 covers ACP pause/resume/cancel, but not queue-vs-steer semantics). Bottom line: the queue-vs-steer-vs-interrupt decision model doesn't have an issue yet. If we want it, the right move is to file one — likely on
software-agent-sdk
(it's the conversation/control-plane concern) with a routing note like #4674 carries, describing the three-way behavior (queue by default, steer as Codex-style inject, interrupt as explicit "stop"), and ACP as the primary driver for parity. Want me to draft that issue?
l
@fresh-machine-37269 open that issue on software-agent-sdk and explicitly use the interrupt/steer/queue terminology, then modify the ACP consistency one to also consider that.
👀 1
f
✅ Done! Both done. 1. New issue created on `software-agent-sdk`: OpenHands/software-agent-sdk#4829 — "Introduce an explicit interrupt / steer / queue control plane for user messages (ACP parity)". It uses the interrupt/steer/queue terminology explicitly, documents the three current defaults (native=queue, Codex=steer, ACP=interrupt/kill), proposes the three verbs, and cross-links #15432, #4194, #4674, #15912, and #16119. 2. ACP issue updated: appended a section to OpenHands/OpenHands#15912 ("ACP: expose pause / resume / cancel…") mapping its pause/resume/cancel vocabulary onto interrupt/steer/queue, flagging that ACP's current interrupt-by-default is the specific inconsistency to fix, and cross-referencing the new #4829. One judgment call to flag: I interpreted "the ACP consistency one" as #15912 (the pause/resume/cancel issue we've been discussing). There's also #15728 ("ACP<->regular-agent alignment cleanups"), which is more about code-level `isinstance`/`acp_server` alignment than message-control semantics. If you actually meant #15728, say so and I'll add a cross-reference there too.
l
ugh, that doesn’t sound right 😭
c
Oof it still got the terminology wrong
l
@fresh-machine-37269 change your model to gpt-5.6-sol, re-think, and try again
👀 1
f
✅ Done! I can't switch my own model — the
gpt-5.6-sol
option you're seeing only applies to the image-inspection tool (
inspect_image_with_vision
), not to the model driving this conversation. So I can't literally "re-think as a different model." What I can do is re-review anything you think I got wrong. If there's a specific part of the analysis or the two issue changes you want me to redo, tell me which and I'll re-examine the code and re-verify: • whether "no open queue-option issue exists" is accurate, • whether the interrupt/steer/queue default mapping (native=queue, Codex=steer, ACP=interrupt) is correct, • whether #15912 vs #15728 was the right "ACP consistency" issue to touch. Which one should I re-check?
c
On a side note, I remember this behavior, “queue for when the agent finished its current turn”, was fun in the old days of GPT-5.0 - GPT-5.3, which were becoming
idle
a lot. People - and myself, were doing some silly stuff like: • send a prompt for a complex task • write a bunch of 10x “continue” messages • go to bed 😅 The harness stored all those “continue”, and was sending them one by one, every time the agent FINISHED or IDLEd. 😅 Though I guess this is what a “conversation finished” hook can do these days… idk.
l
oss-openhands is not having a good day…
c
@bland-bear-604 can you help your sibling here? Note the terminology we want to keep, it’s super important to communicate the ideas here. Use enyst token to edit the last issue posted. Be careful
l
oh, wow it was deepseek pro
👀 1
c
Ah 🥹 I haven’t seen it this confused, but admittedly I think it’s the initial context… and maybe because our code with ACP might use the terms differently…
👀 1
b
Done — corrected both issues to the agreed terminology (Codex App terms), keeping interrupt / steer / queue straight: 1. interrupt — cancel the current tool call and post now. 2. steer — wait for the current tool call to finish, then inject into the same running turn (non-destructive). 3. queue — hold until the agent is FINISHED / IDLE, then send as the next turn's prompt. Support today: OpenHands native = interrupt + steer, not queue. OpenHands ACP = interrupt-by-default only. Codex = all three (steer is the CLI default). The feature Rajiv is asking for is queue, which native doesn't have. • sdk#4829 body rewritten (it had native "queueing by default" inverted): https://github.com/OpenHands/software-agent-sdk/issues/4829 • OpenHands#15912 alignment section corrected to match: https://github.com/OpenHands/OpenHands/issues/15912 Edited via enyst. 🐾
c
It looks good to me, but I don’t know the ACP details
👀 1
l
thanks!
👀 1
c
🙏 I guess cats are good to have around
🐱 1
👀 1