Mid-turn interrupt: this turn, next turn, or refused
The Agent is editing the third file. You see the direction drift and add: config in YAML. After Enter, is this sentence a start, an insert into the current turn, or a refusal on the spot? Core decides now. It does not wait for the model to speak.
This turn
Next turn
Refused at the door
- Dispatch on TurnInputMode, default StartOrSteerturn_input.rs L141
- Try steer_input first; only NoActiveTurn starts workturn_input.rs L195
- Only Regular accepts. Review and Compact refuse on the spot.turn_input.rs L507
- If idle, apply_started then spawn_taskturn_input.rs L242
- An interrupt writes pending and flips the phase back to CurrentTurnturn_input.rs L558
- A final answer defers the delivery phase to NextTurninput_queue.rs L206
- A tool item accepts the phase back to CurrentTurnstream_events_utils.rs L302
- get_pending_input looks at the phase, then decides whether to empty the mailboxinput_queue.rs L297
Three everyday endings all sting. Interrupt now and the first two files may sit half-done on disk. Queue for the next turn and you watch it finish the rest the old way. Stuff it into the current context without waking the loop, and the model sees it only the next time it speaks. Your constraint arrives late.
Codex folds this into one entry and three modes. The caller picks StartOrSteer, StartIfIdle, or Steer — it does not shout start. Core judges from busy/idle and task kind, and returns Started, Steered, or NotSubmitted at once. The decision ends there. It waits for no user-prompt hook, no history persist, no model sampling.
Source: codex-rs/core/src/session/turn_input.rs lines 1–9; codex-rs/protocol/src/turn_input.rs lines 127–136
StartOrSteer follows its name. Try an interrupt first. Only NoActiveTurn leads to apply_started then spawn_task. Other refuse reasons wrap as NotSubmitted. It will not start work in secret. TUI live voice takes this path too, sharing the default entry’s verdict.
Source: codex-rs/core/src/session/turn_input.rs lines 141–156, lines 195–249
Settings cannot change before the verdict. prepare previews thread settings first. A failed preview is InvalidRequest. Real writes happen in apply_started or apply_steered. A refused input does not even change settings. A successful interrupt only persists settings. This turn’s TurnContext does not swap. Start-only options, like a structured-output schema, apply only on Started.
Source: codex-rs/core/src/session/turn_input.rs lines 58–80
Start and steer must finish under the same lock. Check for an active turn, then kind, then write pending. Midway, another submit must not swap the turn. Settings preview first, write second, because a refuse path must leave the thread unchanged. Verdict and enqueue are two lock takes. When Enter and a sub-mail arrive together, you can get a window that looked idle at judge time and occupied at enqueue time.
A review task opens its own one-shot child conversation. A compact task is swapping the window. If the user adds “use YAML” now, no current-turn tool loop can catch it. Open a regular turn for it and the review result and compact summary fight the new conversation for the same active_turn.
steer_input does every check under the active_turn lock. No active turn, or a slot with no task, is NoActiveTurn. TaskKind has three variants: Regular, Review, Compact. Review and compact return ActiveTurnNotSteerable. StartOrSteer will not start work because of that.
Source: codex-rs/core/src/session/turn_input.rs lines 507–519; codex-rs/core/src/state/turn.rs lines 67–72
After the checks pass, user input is pushed into pending_input and the mailbox phase flips back to CurrentTurn. Exhaustive match has a product cost here: add a new task kind and the compiler forces you to say whether it can take an interrupt.
Source: codex-rs/core/src/session/turn_input.rs lines 546–564
Internal tasks have their own lifetime. Weld a user interrupt into a review turn and two jobs fight one exec slot. The caller gets a refuse. This input will not be silently queued into a new conversation. Rewrite in another language and the rule stays: tasks that can take an interrupt and tasks that cannot must be split in the type system.
The main agent already printed something that looks like a final answer. A sub-agent sends progress at the same time. Merge them and the answer the user already saw gets continued. Always wait for the next turn and the sub-agent result may skip a sample before it reaches the model.
A user interrupt enters in-turn pending_input. A sub-agent letter goes to session-level mailbox_pending_mails. Whether the two stocks merge into this turn is MailboxDeliveryPhase. The phase starts at CurrentTurn. After the user has seen a final answer it flips to NextTurn. One more user interrupt, or another tool call, reopens the phase.
Source: codex-rs/core/src/state/turn.rs lines 37–56
Flipping to NextTurn has one exception: if pending_input still has a sub-mail that is not “queue and don’t wake,” the current phase stays. Taking mail looks at this card too. On NextTurn, in-turn pending is not taken, and the session mailbox is not emptied. On CurrentTurn, take in-turn pending first, then empty the session mailbox and append it. So after a final answer lands, a sub-mail can lie in the mailbox while the loop thinks there is no pending input, and this turn closes.
Source: codex-rs/core/src/session/input_queue.rs lines 206–227, lines 284–336
What counts as a user-visible final answer? Assistant body text. phase Commentary does not count, nor does a trim-empty body. An unlabeled assistant message is treated as a final answer. An unlabeled provider defaults to the safer path: close the mailbox to the next turn first. Approval, permission, ask, elicitation, and dynamic tools are five separate oneshot tables. They do not enter this mailbox. Each waits for its own receipt.
Source: codex-rs/core/src/stream_events_utils.rs lines 486–501; codex-rs/core/src/state/turn.rs lines 87–103
“The user already saw the answer” is a product boundary. It does not depend on Rust or a mailbox implementation. Rewrite in another language and you still need a flip-card: late sidecar messages do not continue an on-screen answer by default. Clear same-turn work — another user interrupt, another tool call — opens the door again.
DSH: two parameters, two tracks
DSH exposes three aliases on one send. Target queue and whether to wake are two orthogonal parameters. followup owns a turn and wakes. steer inserts at the next stop and wakes. inject boards without nudging the driver. Inbox is two durable lists, next-turn and next-step. claim empties next-step first, and takes one more when the target is next-turn.
Codex has no three public functions. StartOrSteer welds idle-start and busy-steer into one verdict. DSH can skip the flip-card because wait-for-the-turn and wait-for-the-next-stop are two lists. The cost: after the answer is on screen, a non-empty next-step still keeps the current Turn alive.
Claude Code: one queue, priority fills in the timing
In the reconstructed source, user input, task notices, and orphan permissions share one commandQueue. Priority is now > next > later, FIFO inside a level. User commands default to next, task notices to later, so user input is not starved by system messages. Consume happens after the current stream ends. There is no step-level interrupt. That YAML constraint you added waits for the current generator to finish before it reaches the model.
After the answer is on screen, when is this sub-mail seen?
After the final answer lands, enqueue a trigger_turn: false sub-mail, then feed a FunctionCall. On paper, walk what get_pending_input should return.
The path to check: when the body persists, the phase flips to NextTurn and this letter is invisible. After a tool item arrives the phase reopens, and only the next lap can take it into the same turn’s next model request.