Skip to content

fix(native-chat): a Stop Codex took settles the message whose turn never opened (follow-up to #25217) - #26105

Merged
brennanb2025 merged 5 commits into
mainfrom
brennanb2025/stop-settles-unopened-send
Oct 7, 2026
Merged

brennanb2025 merged 5 commits into
mainfrom
brennanb2025/stop-settles-unopened-send

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​292 $\color{#cf222e}{\Huge{\mathbf{−}}}$​54 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​238
Prod 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​126 $\color{#cf222e}{\Huge{\mathbf{−}}}$​93 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​33

ELI5

You send Codex a message, then press Stop before Codex has really started on it. Codex says "OK, stopped", but never tells Orca the work began or ended. Orca kept waiting for it, so the chat said "Working…" forever. Now Orca treats Codex's "OK, stopped" as the answer: the message is marked as stopped before the agent started, and the chat is ready again.

What Changed

The problem (found in #25217's live QA, flow r18-1). In a Codex chat, a message was handed to Codex, and Codex accepted it but had not yet started its turn. The person pressed Stop. Codex accepted the Stop and then sent nothing more for that turn. The message stayed "pending" forever, so:

Why: when Codex takes a Stop, Orca ends the stopped turn by marking its records interrupted. A turn that never started has no records, so nothing was marked and the message was never settled. Only one path settled such a message: the Codex process ending after a Stop.

What the person sees now. The stopped message stays where it was sent, with one "Stopped before the agent started" row under it (the existing row from #25051). There is no extra "Cancellation requested." row. Send comes back, the chat stops reading "Working…", and the next message goes out as its own turn with its own timer.

The mechanism. When Codex takes the Stop and names the turn it stopped, and that turn has no record in the chat's journal, Orca withdraws the message the same way it already does when the Codex process ends after a Stop (withdrawCodexSendsNoTurnOpenedFor). It is one rule with a second trigger, not a second rule. Working, the timer and the hold all follow from the message being settled.

Real Codex can also abort a turn before it starts: it answers the Stop, then sends a turn-end for a turn that never sent its start. Orca used to record that as an empty "interrupted" turn. The person then saw an empty turn next to the message's own stop row, and if the end was read before Codex's answer to the Stop, the message was never withdrawn. Codex saves a turn's prompt only once the turn starts, so an interrupted turn this Codex process never started, with no item or prompt read in it, now gets no record. A failed end still gets its record, because that record carries the error.

Deleted: #25217's Stop-note branch in the opening-message hold (src/shared/structured-agent-session-opening-send.ts) and its unit tests. The message is now settled, so nothing reaches that branch. The Stop-note key moves back next to its only users (structured-agent-session-command-turn.ts, the same text as before #25217).

Production lines: +126 / −93 across 9 files. 37 of those lines are moved, not new: the existing stopped-turn settle and the Stop-note key.

Why

The common pattern settles stopped work from the Stop's own successful result, even when no turn ever started. It does not wait for a turn-ended event that may never come. Orca already did this for a turn that had started. This change closes the gap for one that had not.

Alternatives considered:

Differences from the common pattern:

  • Intended: Orca settles only after the provider takes the Stop. The common pattern can end a run with no provider turn before asking the provider. Here the message was already handed to Codex, which could still open its turn. A Stop that was refused or not confirmed goes to the existing path that ends the Codex process, and that end settles the message.
  • Intended: this change is Codex-only. Claude's Stop names no turn, but Claude and the Agent Client Protocol agents (Grok) always end the agent process on Stop, and that end settles the message. A new test pins this for Claude with the real adapter.

Linked Issue

Follow-up to #25217 (live QA r18-1).

Visual Proof

Live QA on macOS with an isolated, hidden dev build of this branch (d16040a), using a Codex stand-in behind Orca's real Codex adapter.

Before (#25217's head, same flow r18-1): after the Stop, "Cancellation requested." shows, the chat stays on "Working…", and the Stop button never goes away.

before: Cancellation requested and Working stuck

After, flow A (same flow; Codex accepts the Stop and never starts the turn): one "Stopped before the agent started" row under the message, no "Cancellation requested.", no "Working…", Send is back.

after A: Stopped before the agent started, Send back

The next message goes out as its own turn with its own clock ("Worked for 3s"):

after A: next message answered as its own turn

After, flow B (real Codex's shape: Codex accepts the Stop, then ends the turn it never started as interrupted). Same row, no empty "Interrupted" turn, and the next message is answered normally:

after B: no empty interrupted turn, next message answered

Unchanged, flow C (the turn opens before the Stop lands): the first message keeps its own turn, and a second message the Stop took back shows its own "Stopped before the agent started" row above "Stopping…":

C: turn that opened keeps its turn

Unchanged, flow D (Codex refuses the Stop before the turn opens): the first message is not withdrawn, and the next message waits until that turn opens:

D: refused Stop keeps the hold

Testing

Automated (explicit file lists, --maxWorkers=2):

  • New host tests against the real host, store, journal and Codex adapter. A Stop Codex takes on a turn it never started withdraws the message, with no extra row and no turn record. All three arrival orders of Codex's turn-end pass: after the answer, before it, and never.
  • Guards, each pinned by a test, and each test fails when its guard is removed:
    • Codex's normal shape withdraws nothing and ends the turn interrupted. In that shape the turn-start is read before Codex answers the Stop.
    • A refused Stop withdraws nothing, even one that names the turn.
    • A message steered into a running turn is never withdrawn.
    • Queued cards are untouched.
    • If the withdrawal itself fails, the Stop is still answered and the failure is logged.
    • A turn that was never started but had an item read keeps its record.
    • A failed turn that was never started keeps its record.
    • A turn ended between a message's acceptance and its handover does not block the withdrawal.
  • The main new test fails on main, where the message stays pending.
  • 308 related test files (3432 tests) pass, plus 36 phone tests for the same shared rule.
  • tc:node and tc:web are clean on current main.

Live QA on macOS (M4Air, isolated dev build, Codex stand-in behind the real Codex adapter): 4 of 4 flows pass (Codex driver, gpt-5.6-terra).

  • A (Stop accepted, turn never opened): the message is withdrawn (journal rejected / cancelled), with no Stop-note row and no turn record, and the next message runs as its own turn.

  • B (accepted, then an interrupted end for a turn never started): same result, and no turn record is written for that turn.

  • C (the turn opened before the Stop landed): unchanged; the turn ends interrupted.

  • D (Stop refused before the turn opened): unchanged; the message is accepted, never withdrawn, and the next message reaches Codex only after the turn opened.

  • Isolation: the app's home and its Codex and Claude config folders were all inside the QA folder, agent-status hooks were off, the stand-ins were pinned by absolute path, and the real ~/.codex, ~/.claude and ~/.orca files were unchanged from start to finish.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Written with Claude (Opus 5.5), reviewed by a separate Claude review pass.

Review

One Claude review round: no blocking findings. It found two real medium issues: an empty interrupted turn appeared when Codex ended a turn it never started, and the withdrawal measured from the message's acceptance instead of its handover. Both are fixed and were confirmed in a second pass.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Author X handle: @BrennanKB5

…n never opened

When Codex takes a person's Stop on a turn it never opened, no turn-ended
event follows, so the message stayed pending: the chat read Working with
Stop shown, and the next turn's clock counted from that message.

The Stop's settle now handles that case from its own answer: when the
provider names the turn it took and that turn has no record, the sends it
was for are withdrawn by the same rule a Codex child's end after a Stop
already uses. The send's own row then says it was stopped before the agent
started, and Working, the clock and the opening-send hold follow from it
being settled.

Deletes the opening-send hold's special case for a taken Stop's note, which
this makes dead, and moves the Stop note key back next to its only users.
…ough the CLI's end, and the next message goes out
…s nothing, and the withdrawal reads from the handover

Codex can abort a turn before it starts: it answers the interrupt, then
sends turn/completed for a turn that never sent turn/started. The
translator wrote an empty interrupted turn for it, so the person saw that
turn beside the message's own "Stopped before the agent started" row, and
when the end was read before the interrupt's answer the Stop's settle
found a record and withdrew nothing. Codex records a turn's prompt only
once the turn starts, so an interrupted turn this child never started,
with no item or prompt read, now gets no record. Failed ends keep theirs.

The withdrawal now asks whether a turn opened since the send's handover
row, the same point the opening-send hold reads, rather than since its
acceptance: a turn record written in between is not the send's turn.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status at d16040a: ready for review once CI finishes. The coordinator flips it to ready; this PR must not be merged by an agent.

The problem: in a Codex chat, pressing Stop before Codex had started the turn left the message pending forever. The chat stayed on "Working…" with the Stop button showing, and the next turn's clock counted from the stuck message. Now that message is marked "Stopped before the agent started", Send comes back, and the next message runs as its own turn. Screenshots of before and after are in the PR description.

  • Review: one Claude round. No blocking findings. It found two real medium issues: an empty "interrupted" turn appeared when Codex ended a turn it never started, and the withdrawal measured from the wrong point. Both are fixed, and a second pass on the fix commit found nothing.
  • Automated tests:
    • 308 related test files (3432 tests) pass. Each safeguard was removed one at a time, and its test failed each time.
    • The main new test fails on main.
    • tc:node and tc:web are clean.
  • Live QA on macOS with an isolated, hidden dev build: 4 of 4 flows pass.
    • Codex accepts the Stop and never starts the turn: the message is withdrawn and the next message goes out.
    • Real Codex's order, where Codex accepts the Stop and then ends the never-started turn as interrupted: same result, with no empty turn.
    • The turn opened before the Stop landed: unchanged.
    • Codex refuses the Stop before the turn opens: unchanged, and the next message still waits.
    • The real ~/.codex, ~/.claude and ~/.orca files were unchanged throughout.
  • Not covered: Windows and Linux; the change has no platform-specific code. A real Codex run of the abort-before-start case; the stand-in reproduces the order Codex's source shows.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 7, 2026 08:02
@brennanb2025
brennanb2025 merged commit e6c298e into main Oct 7, 2026
56 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant