The stream is still going. Tools already started.
The model is still typing. The sound of reading a file already started. Sampling’s timing is: pin futures inside the stream, drain after it. Persist first, then wait for results.
- An SSE frame first becomes a generic event, no business meaning yetresponses.rs L164
- kind output_item.done yields OutputItemDoneresponses.rs L352
- The sampling loop hands it to handle_output_item_done immediatelyturn.rs L2384
- Write the function_call to history and rollout firststream_events_utils.rs L316
- Then pin the tool and push the ordered queuestream_events_utils.rs L320
- Stream end, break, or cancel only leaves the receive loopturn.rs L2282
- drain writes results to history in insert orderturn.rs L2135
- Only then check the cancel token; Stream is retryableturn.rs L2760
You ask the model to read three files and write a summary. The screen is still typing. The sound of reading a file already started. Then you hit Esc. The UI stops. History still keeps that request, sometimes the result too. You thought cancel meant nothing happened. The runtime does not book it that way.
The more common case: the model already emitted two function_calls, a third is still on the way, and SSE closes before response.completed. Should the next retry see empty history, or calls and results already persisted?
Wait for Completed to write, and an early close throws away calls that were already complete. A retry makes the model emit the same calls again. Skip the write on cancel and history keeps a half request. Model and UI both see an unclosed call.
OutputItemDone is the parse layer folding one response.output_item.done frame into a business event. The moment it arrives, sampling writes it to session history and rollout, then wraps tool exec as a future on the ordered queue. Cancel uses a child token: when the parent lights, this tool stops with it. However fast cancel arrives, this function_call is already in history. At most you write one more aborted by user.
Source: codex-rs/core/src/stream_events_utils.rs lines 190–192; lines 316–327. The comment above the type alias locks the contract: finished model output is recorded immediately. If the turn later cancels, history and rollout stay in sync.
History only appends. It does not rewrite. The tool already read disk. That fact happened. A cancel tree can interrupt exec. It cannot unwrite a request already on disk. Write the request first, the result second, and the transcript stays closed. The contract is not Rust-specific. In TypeScript it is still append then push a promise.
Source: AGENTS.md lines 91–100, Model visible context, first rule: No history rewrite.
The model often emits a file read, then keeps writing an explanation. Wait for the closing whistle to start work, and you serialize file-read latency with typing latency. Starting inside the stream overlaps those two. The cost: cancel and a broken stream must claim futures already running. No owner, and you get orphans — tools still running, history out of sync.
The receive loop — clean Completed, early close, or or_cancel — only leaves the loop. The function has not returned. It always calls drain_in_flight, waits for each future in insert order, writes history, and only then checks the cancel token.
A broken stream takes Stream and is retryable. A retry rebuilds the prompt from clone_history; calls and results already written stay. Esc takes TurnAborted and is not retryable. Running tools write abort copy. drain writes that as ordinary output.
Source: codex-rs/core/src/session/turn.rs lines 2282–2284; lines 2744–2762. codex-rs/protocol/src/error.rs lines 88–93, lines 364–390.
Start mid-stream and every exit must wait for the set. Ownership stays in the sampling function’s locals. There is no second background reaper. Success, error, and cancel share this wrap-up. Same in another language: leave the async loop, allSettled first, then decide retry or abort.
Three tools may run at once. Whoever finishes first, history still writes results in the order the model emitted them. Write in completion order and the same session replayed twice may diverge; the prompt cache gets more brittle too. The ordered queue splits observation order from exec order. The concurrency gate lives on another layer. Here, just this: pin order is later drain order.
Source: codex-rs/core/src/session/turn.rs lines 2130–2154; lines 2391–2397.
Claude Code: wait for the stream by default, plus one in-stream gate
On the default path the stream loop only collects tool_use, and runTools starts after for await ends. A broken stream just drops collected blocks. You skip local ownership that drains at every exit. The cost: tool latency and typing latency serialize.
When streamingToolExecution is on, behavior moves toward Codex: addTool inside the stream, start immediately. Failure rollback must discard tools already running so old ids do not leak into a retry. Codex has no matching discard, because it persists first and retries read history.
DSH: a three-stage waterfall plus a monotone Guard — who may refuse
DSH’s entry is an already-formed tool call. pre / guard / around / post answer who may refuse, and whether a result remains after refusal. Guard has a refuse reason or an abstain — no “allow” option. Its drained is wrap-up inside one execute. While SSE is still in flight, this waterfall has not started.
The words look close. The exits differ. One guards monotone permission. One guards a closed streaming transcript. Move Guard into Codex and it will not stop a broken stream from dropping transcript. Move persist-then-drain into DSH and it will not answer whether a plugin can turn refuse into allow.
Source-checked on both sides · tools/src/index.ts lines 1–4, lines 703–711, lines 1328–1337 · DSH · three-stage waterfall and monotone GuardSwap write and pin
In the tool branch of handle_output_item_done, swap record_completed_response_item and Box::pin(handle_tool_call). Cancel happens before pin, before persist. What do the next sample and session restore see?
Pin the answer on history only appending, and on when drain_in_flight writes.
OutputItemDone, write first, then start. Every stream exit drains first. Results write in emit order. Cancel interrupts exec. The transcript already written stays.