Repository navigation
feat(native-chat): the chat reads "Stopping…" from your Stop until the turn actually ends - #24369
Conversation
…keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded.
… still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it.
…the test's budget
…ry path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again.
…op-keeps-steered-card
… queued receipt's card
…op-keeps-steered-card
…journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety.
…ead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule.
… in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.
…op-keeps-steered-card
The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists.
A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send.
- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind.
…op-keeps-steered-card
…turn and caller
The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.
…states the same Stop event
… the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick.
…the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane.
… Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses.
…op-keeps-steered-card
…op-keeps-steered-card
… a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue.
A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes.
…r lifts a person's Stop
…-state-on-b # Conflicts: # src/renderer/src/components/native-chat/NativeChatComposer.tsx # src/renderer/src/components/native-chat/NativeChatComposerActions.tsx # src/renderer/src/components/native-chat/use-native-chat-dictation.ts
…-state-on-b # Conflicts: # tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
…-state-on-b # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
…-state-on-b # Conflicts: # src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx # src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx # src/renderer/src/components/native-chat/use-structured-agent-session.ts
…-state-on-b # Conflicts: # mobile/src/session/MobileNativeChatTurnStatus.test.ts # mobile/src/session/MobileNativeChatTurnStatus.tsx # mobile/src/session/MobileNativeChatView.tsx # mobile/src/session/use-mobile-native-chat-turn-disclosure.ts # src/renderer/src/components/native-chat/NativeChatMessageList.tsx # src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx # src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
🟡 Minor · Keep failedOn when a repeat press reopens the settle. · journal-stop-turn-end.ts:145-146
src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts:145-146
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winKeep
failedOnwhen a repeat press reopens the settle.
beginJournalStopSettlerebuildsstop.settlefromsettlingandturnIdonly. Before this PR that was enough. NowstructuredAgentSessionStoppingalso readssettle.failedOnto keep "Stopping…" on the turn a failed Stop could not stop.Trigger:
- A Stop is pressed before the turn opens. Its interrupt fails, so the settle closes with a
failedOnmark.- The turn opens and reads "Stopping…".
- The person presses Stop again (
reach === 'repeat',opensSettle).beginSettledropsfailedOn.- The provider answers
{ cancelled: false }with norefusal. TheninterruptFailedis false, socancelAndNotesets neitherbinding.turnIdnorbinding.failedOn.After
settled, the settle binds nothing. "Stopping…" clears while the turn still runs, and the steer hold lets queued sends steer into that turn. Keep the earlier mark, as the code already does forturnId.Proposed fix
- stop.settle = { settling: true, ...(stop.settle?.turnId ? { turnId: stop.settle.turnId } : {}) } + stop.settle = { + settling: true, + ...(stop.settle?.turnId ? { turnId: stop.settle.turnId } : {}), + ...(stop.settle?.failedOn ? { failedOn: stop.settle.failedOn } : {}) + }
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
0e652920-289b-4fca-ba48-b38a48263a4d
📒 Files selected for processing (120)
mobile/src/session/MobileNativeChatLiveLine.android.test.tsmobile/src/session/MobileNativeChatLiveLine.test.tsmobile/src/session/MobileNativeChatLiveLine.tsxmobile/src/session/MobileNativeChatOverlay.tsxmobile/src/session/MobileNativeChatView.test.tsmobile/src/session/MobileNativeChatView.tsxmobile/src/session/MobileNativeChatView.turn-status.test.tsmobile/src/session/mobile-native-chat-composer-placeholder.test.tsmobile/src/session/mobile-native-chat-composer-placeholder.tsmobile/src/session/use-mobile-native-chat-turn-disclosure.tsmobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsxmobile/src/session/use-mobile-structured-agent-session.tsmobile/src/session/use-mobile-structured-send-with-outcome.tsmobile/src/session/use-mobile-structured-turn-indicator.test.tsxmobile/src/transport/rpc-subscription-inventory.tsmobile/src/worktree/agent-row-display.tssrc/main/agent-hooks/first-work-branch-rename.test.tssrc/main/claude/claude-background-wake-retention.test.tssrc/main/codex/codex-structured-rewind.tssrc/main/codex/codex-structured-session-acquire.tssrc/main/codex/codex-structured-session-state.tssrc/main/codex/codex-structured-turn-cancellation.tssrc/main/codex/codex-structured-turn-start.tssrc/main/native-chat/agent-session-journal/journal-reducer.tssrc/main/native-chat/agent-session-journal/journal-stop-marks.tssrc/main/native-chat/agent-session-journal/journal-stop-note-projection.tssrc/main/native-chat/agent-session-journal/journal-stop-turn-end.tssrc/main/native-chat/agent-session-journal/queued-message-pause.tssrc/main/native-chat/agent-session-wire/agent-session-journal-batch.tssrc/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.tssrc/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.tssrc/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversations.tssrc/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.tssrc/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.tssrc/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-rewind.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-feed.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.tssrc/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.tssrc/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stopping.tssrc/main/native-chat/agent-session-wire/structured-agent-session-subscribers.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.tssrc/main/native-chat/agent-session-wire/structured-conversation-compaction.test.tssrc/main/native-chat/agent-session-wire/structured-rewind-recovery.tssrc/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.tssrc/main/runtime/structured-agent-session-codex-interrupt-order.test.tssrc/renderer/src/components/native-chat/NativeChatComposer.test.tsxsrc/renderer/src/components/native-chat/NativeChatComposer.tsxsrc/renderer/src/components/native-chat/NativeChatComposerActions.tsxsrc/renderer/src/components/native-chat/NativeChatComposerField.tsxsrc/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsxsrc/renderer/src/components/native-chat/NativeChatMessageList.tsxsrc/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsxsrc/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsxsrc/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsxsrc/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsxsrc/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsxsrc/renderer/src/components/native-chat/NativeChatStructuredSession.tsxsrc/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsxsrc/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsxsrc/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsxsrc/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsxsrc/renderer/src/components/native-chat/native-chat-composer-placeholder.test.tssrc/renderer/src/components/native-chat/native-chat-composer-target.tssrc/renderer/src/components/native-chat/native-chat-composer-types.tssrc/renderer/src/components/native-chat/native-chat-interactive-prompt.tssrc/renderer/src/components/native-chat/native-chat-structured-stop-controls.tssrc/renderer/src/components/native-chat/native-chat-transcript-slots.tssrc/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.tssrc/renderer/src/components/native-chat/structured-agent-session-outbox-retry.tssrc/renderer/src/components/native-chat/structured-agent-session-outbox-storage.tssrc/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsxsrc/renderer/src/components/native-chat/use-native-chat-transcript-slots.tssrc/renderer/src/components/native-chat/use-structured-agent-session-outbox.tssrc/renderer/src/components/native-chat/use-structured-agent-session-stop.tssrc/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsxsrc/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsxsrc/renderer/src/components/native-chat/use-structured-agent-session.tssrc/renderer/src/i18n/en-runtime-required.jsonsrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/fr.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/renderer/src/lib/agent-row-stopping-label.tssrc/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.tssrc/renderer/src/runtime/structured-agent-session-status-feed.tssrc/shared/agent-session-status-snapshot-fold.tssrc/shared/agent-session-wire.tssrc/shared/agent-status-types.tssrc/shared/agent-stop-display-status.test.tssrc/shared/agent-stop-display-status.tssrc/shared/native-chat-live-line.test.tssrc/shared/native-chat-live-line.tssrc/shared/native-chat-turn-membership.tssrc/shared/native-chat-turn-status.tssrc/shared/native-chat-types.tssrc/shared/structured-agent-session-message-projection.tssrc/shared/structured-agent-session-outbox-delivery.tssrc/shared/structured-agent-session-outbox.tstests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.tstests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.tstests/e2e/cross-version-wire/cross-version-stop-note-scenarios.tstests/e2e/cross-version-wire/cross-version-stop-note-test-rig.tstests/e2e/cross-version-wire/versioned-agent-session-wire.ts
🚧 Files skipped from review as they are similar to previous changes (7)
- src/renderer/src/i18n/en-runtime-required.json
- src/renderer/src/i18n/locales/en.json
- src/renderer/src/i18n/locales/ko.json
- src/renderer/src/i18n/locales/es.json
- src/renderer/src/i18n/locales/zh.json
- src/renderer/src/i18n/locales/ja.json
- src/renderer/src/i18n/locales/fr.json
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.
…-state-on-b # Conflicts: # mobile/src/session/MobileNativeChatView.tsx # mobile/src/session/use-mobile-structured-agent-session.ts # src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
…e limits after the main merge The Stop control now disables itself while Stopping, so the composer passes the flag through and its test file stays as main has it. The phone chat header's Stop moves to its own component.
…n every snapshot Every snapshot projects each item through the Stop-note read, and each new snapshot rebuilds the index of Stop notes by turn. Both parsed every item's key first; they now check the row's kind and turn scope (and, for the projection, its unconfirmed-stop failure) before the parse. Every Stop note is a status row, so what each finds is unchanged.
…5/chat-ui-duplicate-surfaces Conflicts: main moved the approval mapping into chatApprovalFromJournal (#24369); it now omits the ask rule as this branch's card does.
…r-stop Main's #24369 adds a Stopping state to the composer's Stop control and its own stop controls; the composer keeps this branch's Resume action beside it (Stopping… / Stop / Resume / Send), and Stop stays live only once a turn can be stopped. Main's new Stopping test passes the primary action this branch requires. The composer's queue field props come from the queue's composer hook as one object, to stay under the file's line limit.
…s-after-stop' into brennanb2025/stop-queue-after-stop Composer: Stop reads Stopping… while this client's Stop is in flight (#24369) and gives way to Resume when the queue is held and idle (#24586). Stop is offered while the queue sends next but live only once a turn can stop. queued-stop-row test takes #24586's version.
Step 2 of 2, stacked on #24864. Step 1, #24864, makes a Stop bind only the turn it actually stopped, decided when the Stop settles; this PR reads that binding to show "Stopping…". It also builds on the Stop event (#24072), its turn-end rule (#24311), one Stop note per turn (#24301) and #24334 (a Codex Stop that Codex refuses or never answers ends the Codex process), all on main.
Scope. This PR is the "Stopping…" state, the host holding any message sent while a Stop ends the turn, and what such a message looks like while queueing is off (as it is on every shipped host). Queueing a message sent while Stopping as a card, and how such a card runs past the cards a Stop paused, are split out to the draft follow-up #25041, which lands with the queued-messages capability rollout (#21062).
ELI5
When you press Stop in a native chat, the agent does not stop instantly. Orca asks it to stop, and it can keep printing for a few seconds before it does. Until now the chat said "Working…" the whole time, so it looked as if Stop did nothing. Now every place that shows the agent says "Stopping…" from the moment Stop takes effect until the turn has actually ended. The Stop button stays usable, so if a stop gets stuck you can press it again.
What Changed
The problem. After you press Stop, Orca sends the interrupt and, for Claude, ends the agent's process once a short grace period passes. Text can keep arriving during that time. The chat, the sidebar, the phone and
orca worktree psall kept reading "Working…" until the turn's end was recorded, so the Stop looked ignored.What you see now
orca worktree ps --jsonmainAgent: { state: "working" }mainAgent: { state: "working", stopping: true }"Stopping…" shows while your Stop is still being carried out, and then for as long as the turn it stopped runs: until the agent's interrupted result, the end the host records when it ends the agent's process, or a relaunch settling the turn. A Stop the agent declines, or that Orca cannot confirm, keeps "Stopping…" until that turn ends: the Stop is still yours, the note says what happened, and Stop stays enabled so a second press can push it through. A Stop that finds nothing to stop (you pressed it before Codex had opened the turn and the turn never opened) ends "Stopping…" as soon as it is carried out, so a message you send next goes out normally and a turn that opens later never reads "Stopping…".
A Claude Stop whose process end fails (the end times out, or the process can't be proven gone) while the turn is still running now says so: the Stop's note changes to "Cancellation was not confirmed.", the same row an interrupt that fails outright writes, and "Stopping…" holds until that turn ends. Before, the note kept saying "Cancellation requested." with nothing to show it had failed. Pressing Stop again retries the end. Once that turn has ended interrupted (the work stopped, however that was proven: a later Stop, the process exiting on its own, the agent's own interrupted result, or the host proving the process gone after a restart), the note reads "Cancellation requested." again.
A Codex Stop that Codex refuses or never answers ends the Codex process (#24334), so its turn ends and "Stopping…" ends with it.
The mechanism
The host works it out from fix(native-chat): a Stop binds only the turn it actually stopped #24864's binding. On every journal publish, and whenever a Stop's settle opens or closes, the host's status feed calls one function,
structuredAgentSessionStopping(structured-agent-session-stopping.ts). It reads the latest Stop event (fix(native-chat): Stop's pause is worked out from the chat's history, so a steered message is never re-sent #24072), which must be a person's Stop or close, and what fix(native-chat): a Stop binds only the turn it actually stopped #24864 bound to it:Whether a Stop is settling lives in the host's memory, as fix(native-chat): a Stop binds only the turn it actually stopped #24864 keeps it, so a settle edge writes no row. It bumps a revision the status projection keys on, and a narrow settle-edge hook republishes the session's status and wakes the steer hold's handover. Nothing else: it is not a journal commit, so it delivers no history, counts as no activity for the idle sweep, and schedules no queue drain.
The Stop's note. It is keyed by the turn it stopped (fix(native-chat): every chat action press is its own action (re-land #23916 on main) #24301), so every press of a Stop on that turn rewrites one row, and the newest press's answer is the row, as on main. What the Stop did to the turn is carried by the turn's own label, bound at settle (fix(native-chat): a Stop binds only the turn it actually stopped #24864). A Claude Stop whose process end fails while the turn runs on revises the note it wrote, which it carries with it, to "Cancellation was not confirmed.", and keeps "Stopping…" on that turn (display only) until it ends.
"Cancellation requested." again once the turn has ended interrupted, worked out on read. Nothing extra is stored: the note keeps the failure it recorded, and the turn records its own end. When the host publishes journal state, a note reading "not confirmed" whose turn has ended interrupted is published as the ordinary "Cancellation requested." note, with the same identity, revision and position (
journal-stop-note-projection.ts, called from the snapshot). That reaches every client through the forms they already read:agent-session-journal-batch.ts), so an open chat updates in the same frame. History pages carry the note alongside its turn's end.agent-session-stop-note-refresh.ts). That covers a client that dropped the live update for a note older than its loaded window, then loaded an older page captured before the turn ended (STA-9612; released clients do this). It writes no row and invents no revision.Published on the session status. The session summary gains an optional
stopping: true, present only while its status isworking. The main agent's row carries it asmainAgent.stopping, so the sidebar, the dashboard,worktree psand the phone's list read it from the row they already read.Clients only show it. The desktop chat reads the host's flag from the status stream it already subscribes to. The phone's chat now reads the same stream,
agentSession.subscribeStatus, which this PR makes callable from the phone:src/shared/agent-session-status-snapshot-fold.ts, so the desktop and the phone handle each frame the same way.What the new phone method exposes. The stream carries each structured session's summary: its status, latest prompt, last reply preview, tool line, verdict and child work. The phone already reads all of that through the
worktree psagent rows and the transcript itself (agentSession.history). The summary also carries the provider's session id, which the phone already receives on its agent rows, and the host's bookkeeping for each session: which agent process is running, whether it has started, and the model in force. None of it is a path, an environment value or a credential.One display status. One shared function,
agentStopDisplayStatus, turns "is the agent working", the host's flag and this device's own in-flight Stop request into the display statusstopping. The chat pane (live line and composer), the sidebar and dashboard rows, and the phone's chat all read it; none combines the flags itself. The phone's worktree list repeats the same test inline, because that file is loaded on its own by the cross-version tests and cannot import a value.The press flag. Each chat also treats its own in-flight Stop request as "Stopping…", so the line changes on click, before the host's Stop event arrives. The flag lives only in memory, is never sent, and clears when the request answers or fails. It is also the only thing that disables Stop.
Nothing steers into a turn a Stop is ending. The host owns this rule. At the moment it would hand a waiting message to the agent, it reads its own "Stopping…" (the same reading the status feed publishes) and holds the message while it holds: a card's Send-now and a send made while the turn stops both wait, and go out as their own turn once the stopped turn ends, or as soon as a Stop that stopped nothing settles. The turn's end, and the settle's close, wake the handover again. A second Stop withdraws a held message, as it withdraws anything waiting. A card queued while stopping already waited for the turn to end. Clients only follow the host: while the chat reads "Stopping…" (the host's flag or the device's own Stop request), they disable a card's Steer and the desktop's steer shortcut, and the composer's placeholder says a message runs after the stop. A message sent while the chat reads Stopping is drawn after the "Stopping…" line until the host hands it over; one sent just before the Stop stays in the turn the host steers it into.
A Codex turn whose interrupt Codex answered is never steered. Codex answers an interrupt as the turn aborts, before it sends that turn's
turn/completed. Until that end arrives, the adapter still counted the turn as running, so a message handed over right after the Stop settled (the Stop's own end row already reads the turn ended) went out asturn/steer, which Codex refused, and only then asturn/start. The adapter now marks a turn whose interrupt Codex answered as aborted, never steers into it, and opens the message's own turn directly. A steer into a turn that is genuinely running is unchanged.Losing contact never leaves it up. When the status stream to a host drops, both clients remove
stoppingalong with the host's ownership fields. When the host lets go of a session, it removesstoppingfrom the summary it keeps. The phone's list now notices whenstoppingis the only field that changed.Mixed versions
stoppingis an optional field it doesn't know, so it shows "Working…" until the turn ends, as today. A cross-version test runs the v1.4.214 summary fold and phone row reader against this host's output.stopping, so the chat shows only the press flag, then "Working…". A new phone on a host from before this PR gets a refusal for the status stream and treats it the same way. A cross-version test checks that v1.4.214 advertises the feed but refuses it to phones.Why
docs/reference/agent-status-store.md). If clients worked it out, the desktop, the phone andworktree pscould disagree./compactwhose interrupt Codex took but never answered, the second Stop ends the agent's process; for Claude it retries the process end. An earlier draft of this PR disabled Stop while Stopping. A test that drove a/compactStop that Codex never answered showed the chat stuck on "Stopping…" with a disabled button, and closing the chat was the only way out.Differences from the common pattern
Only real departures in how the stop state is owned, delivered, kept or ended are listed here.
/compact; a second press is what ends it. Orca-observed: with Stop disabled, a/compactStop that Codex took but never answered left the chat on "Stopping…" with no way out but closing the chat.stoppingis an optional flag on theworkingstatus, not a new status value.working, every older client, every status reader and every dot keeps reading the turn as working, which it still is; only readers that know the flag show "Stopping…".Follows the common pattern: "Stopping…" is worked out by the host that runs the agent, from what it already holds, and is never stored on its own; it runs from the Stop until it settles; a Stop that settles having stopped nothing ends it, and a failed one holds it until the turn ends; a message sent while the turn stops runs after the stop, never steered; where the host queues sends, the composer's placeholder reads "Queue a message to run after the stop"; Steer is unavailable while stopping; the chat reads its own in-flight Stop request as "Stopping…" until the host's flag arrives; and a second Stop hands a held message back.
Behaviour notes
Linked Issue
Part of the Stop-event plan (#24072 and #24311, both merged). Step 2 of 2; step 1 is #24864.
Visual Proof
Round 17, the current head 970c6a1, after the
mainmerges (desktop on a separate Mac, background rig). It covers the four things those merges could have touched: a normal Stop, a Stop the agent never answers, a send while stopping, and rewind next to Stop.Live QA on a separate Mac against background builds of this branch, driving a native Codex chat backed by a stand-in Codex that can accept a Stop slowly, or open a turn never. Desktop: every case passed. Phone: not run (see Testing). The flows that check the never-started send are in #24864.
A normal Stop the agent takes while it keeps printing (round 9, head e1d45a1; nothing on this path changed since). After the press the chat line and the sidebar agent row read "Stopping…" while text still arrives. The turn header keeps its "Working for Ns" timer until the turn ends, then reads "Interrupted after Ns". "Cancellation requested." is recorded under the stopped turn and stays folded with it, so it is not shown here.
A message sent while Stopping (round 10, head 4888740). The composer reads "Send a message to run after the stop". The message is drawn after the "Stopping…" line, not inside the turn being stopped, and nothing is pushed into that turn. Once it ends "Interrupted", the message starts its own new turn.
QA also saw: the "Enjoying Orca?" pop-up on a first new chat can sit over the Stop button and catch the first click (not from this PR; that pass was set aside).
Testing
I manually tested these changes locally (live desktop QA on a separate Mac, see Visual Proof; phone not run)
Automated tests added/updated
Host, through the real host and its status feed (
structured-agent-session-stopping.test.ts,structured-agent-session-status-feed-stopping.test.ts,structured-agent-session-stopping-journal.test.ts). These cover:"Cancellation requested." once the turn ended interrupted (
agent-session-stop-note-projection.test.ts; shipping-adapter scenarios instructured-agent-session-claude-stop-note-end.test.tsandstructured-agent-session-codex-stop-note-end.test.ts: a late process exit, a second Stop, a send, and the agent's own interrupted result). The snapshot and the live stream both read "Cancellation requested.", the stored note keeps its failure, revision and position, and no row is written. Completed, running, unverifiable and missing turns, and other failures, stay unchanged. Pages in both arrangements, byte-bounded catch-up, and the reconnect refresh are covered. Rewind keeps the stored failure (structured-agent-session-rewind.test.ts). With the read rule switched off, 16 of these fail, including all eight adapter scenarios.Released clients (
tests/e2e/cross-version-wire/): v1.4.219 and v1.4.220, with their own reducers and transcript code, render "Cancellation requested." from a page and from a live batch through the real host paths, and recover the stale-page case on reconnect.Send while stopping, queue dark: the composer reads "Send a message to run after the stop" (desktop and phone), the desktop sends plain and marks only a send made while the chat reads Stopping (
use-structured-agent-session.queue-while-stopping.test.tsx, real outbox; a send made just before the Stop is not marked), and draws only that message after the "Stopping…" line until the host hands it over, while one sent before the Stop stays in the turn (NativeChatMessageList.stopping-turn-bar.test.tsx). Each was checked by breaking it.No steer into an aborted turn: with Codex answering the interrupt before the turn's end, a message sent behind the Stop opens its own turn with
turn/startand noturn/steer(structured-agent-session-codex-interrupt-order.test.ts).Steer while stopping (
structured-agent-session-steer-while-stopping.test.ts): a card's Send-now and a send made while a Stop ends the turn, including a Stop the agent declined, are not handed to the agent, and are sent as their own turn once the turn ends; the same holds for a send a Stop overtook while its handover waited on the agent's start; a send made after a Stop pressed before the turn showed is handed over once that Stop settles having stopped nothing, with no "Stopping… → Working… → Stopping…" flip when the turn then opens; and the hold adds no journal read of its own per commit.The bounded read: the derivation reads nothing before the running turn, however long the chat before it.
One display status:
agentStopDisplayStatusreads stopping from the host's flag or this device's press only while the agent works; the pane, rows and phone chat read it. The phone works it out once, in its session hook, and its view and overlay read that.The repeated Stop: the
/compactStop that Codex never answers publishes Stopping after the first press, and the second press ends the command and clears it.The row path: a summary with
stoppingreaches theworktree psrow asmainAgent.stoppingand leaves it when the host drops it. The sidebar row follows it even when it is the only field that changed. The compact row leads with "Stopping…". The dashboard row, also used by the sidebar's non-compact mode, shows "Stopping…" in place of its tool line.Desktop: the live line; the turn header keeps its running timer above "Stopping…"; the "run after the stop" placeholder; a card's Steer and the steer shortcut held while Stopping; Stop held only while this client's request is in flight; with the host saying Stopping and no request in flight, Stop is enabled and a press reaches the host; the press flag clears on answer and on failure; losing contact clears the host flag.
Phone:
stopping-only change;endand lost contact drop Stopping;Cross-version:
cross-version-stopping.unit.test.tsruns v1.4.214's summary fold and phone row reader against this host's output, and this build's readers against v1.4.214's. It also checks that v1.4.214 advertises the status feed but refuses it to phones.Runs (named files only,
vitest --config config/vitest.config.ts). Local runs are focused lists; CI is the full run.--no-file-parallelism), at head 4ed011b: 14 files, 88 passed.mainthrough Add a Chat settings page for structured native chat #25685, at head 970c6a1: the renderer Stop, session and rewind files, 24 files, 289 passed; the host Stop-note, stopping and subscriber files, 11 files, 99 passed; the cross-version wire file, 41 passed.tc:nodeandtc:webpass from a fresh build cache. CI at 970c6a1 is the full run and is green.Typecheck:
tc:nodeandtc:web, mobiletsc, and the mobile tests-typecheck ratchet pass.Lint: oxlint, the anti-slop rules and the changed-lines quality gate (against main) pass on the changed files.
Ablations. Each fix was reverted alone, and each revert turned at least one test red. That covers:
endand contact-loss strips, one-per-connection rule, refusal rule and handle release;Localization: the keys
components.native-chat.status.stoppingandcomponents.native-chat.status.queueAfterStopare in all 6 catalogs, translated, and the localization verifiers pass.Not run:
AI Disclosure
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
agentSession.subscribeStatus. What it carries is described under "What the new phone method exposes".Checklist
pnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)