OpenAI Codex · Turn loop

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.

Course goalAfter this lesson you can explain three things. First: why submit must return Started, Steered, or NotSubmitted at once. Second: why review and compact refuse an interrupt, and will not open a new turn for it. Third: after a final answer hits the screen, why a late sub-mail defaults to the next turn — and why one more user interrupt can open the door.
Play first · The same sentence, four moments
Mailbox sort desk: the same constraint, different drop times, different landings
Moment
Four moments share the same input. Idle starts work. Sampling steers. After the answer is on screen, a sub-mail lies down first. In review it is stopped at the door.
Clock · Idle Task · none Phase · —

This turn

pending_input, taken when the phase is CurrentTurn
Empty

Next turn

Session mailbox, queued but not taken when the phase is NextTurn
Empty

Refused at the door

NotSubmitted — settings do not land either
Empty
Waiting to deliver.
Logic trail · each animation step maps to a source span
  1. Dispatch on TurnInputMode, default StartOrSteerturn_input.rs L141
  2. Try steer_input first; only NoActiveTurn starts workturn_input.rs L195
  3. Only Regular accepts. Review and Compact refuse on the spot.turn_input.rs L507
  4. If idle, apply_started then spawn_taskturn_input.rs L242
  5. An interrupt writes pending and flips the phase back to CurrentTurnturn_input.rs L558
  6. A final answer defers the delivery phase to NextTurninput_queue.rs L206
  7. A tool item accepts the phase back to CurrentTurnstream_events_utils.rs L302
  8. get_pending_input looks at the phase, then decides whether to empty the mailboxinput_queue.rs L297
Pick a moment, hit Play. Watch the same sentence enter this turn, lie until the next, or stop at the door.
Where a user interrupt landsIdle is Started. Sampling is Steered. Review is NotSubmitted — it will not secretly open a regular turn.
Where a sub-mail landsOnce the answer is on screen, a late sub-mail lies in the session mailbox. This turn treats pending input as empty.
When the door reopensOne more user interrupt, or another tool call from the model, flips the phase back to CurrentTurn. Backlogged sub-mail rides into the next sample.
Teaching sketch: bins and slips are course metaphors for in-turn pending_input and the session mailbox. Line numbers on the trail match openai/codex commit 4f39251a01.
Idea 1 · Submit returns a verdict at once
What problem it solves

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.

What the idea is

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

User submit TurnInputMode handle Try steer first; start only if idle Started · open a Regular turn Steered · insert into the current Regular turn NotSubmitted · the thread stays as-is
Teaching diagram: The entry only judges. Hooks, persist, and sampling have not started.
Why it lasts

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.

Idea 2 · Only Regular accepts an interrupt
What problem it solves

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.

What the idea is

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

Why it lasts

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.

Idea 3 · One flip-card decides whether you may continue writing
What problem it solves

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.

What the idea is

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

CurrentTurn pending plus mailbox, merge into this turn NextTurn Neither stock is taken Final answer on screen A user interrupt, a tool call, or the model still needs follow-up
Teaching diagram: After the answer is on screen, close the gate. Clear same-turn work opens it again.

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. A late letter does not continue it by default.
Why it lasts

“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.

Side-by-side · another answer to the same question

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.

Source-checked on both sides · 2026-08-22 · DSH · Inbox

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.

Checked against reconstructed source · 2026-08-22 · messageQueueManager.ts
Classroom Exercise
01

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.

Takeaway: Submit returns a verdict at once — accepted or refused — before sampling starts. Only Regular takes an interrupt. Review and compact refuse on the spot. After the answer is on screen, do not continue it by default. Another user interrupt or tool call opens the door.