Skip to content

fix(codex): a message your Codex hook blocks no longer leaves the chat Working forever - #25049

Draft
brennanb2025 wants to merge 26 commits into
brennanb2025/in-doubt-message-no-barrierfrom
brennanb2025/pending-steer-settles-at-idle
Draft

brennanb2025 wants to merge 26 commits into
brennanb2025/in-doubt-message-no-barrierfrom
brennanb2025/pending-steer-settles-at-idle

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​892 $\color{#cf222e}{\Huge{\mathbf{−}}}$​4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​888
Prod 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​233 $\color{#cf222e}{\Huge{\mathbf{−}}}$​16 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​217

Stacked on #25028 (base branch brennanb2025/in-doubt-message-no-barrier, itself stacked on #24340). Retarget to main once those land, and before this is marked ready.

This PR changes 12 code files, and tests:

  • src/main/codex/:
    • codex-structured-prompt-block.ts (new)
    • codex-structured-dispatch-echo.ts
    • codex-structured-turn-end-settlement.ts
    • codex-structured-session-adapter.ts
  • src/shared/:
    • agent-session-failure.ts
    • agent-session-failure-words.ts
    • structured-agent-session-dispatch-rejection.ts
    • structured-agent-session-latest-request.ts
    • structured-agent-session-message-projection.ts
    • structured-agent-session-send-disposition.ts
  • mobile/src/session/: mobile-structured-send-delivery.ts

ELI5

You send a message to Codex, and a Codex hook blocks it. That might be a hook in your Codex settings, the repository's, a plugin's, or one your organization manages, for example one that refuses prompts containing a secret. Codex drops the message and finishes the turn. Orca never noticed, so the chat showed Working, with a Stop button, for as long as that Codex process lived.

After this change, the chat goes idle when that turn finishes, and the next message goes out normally. The blocked message stays in the chat as a sent message: your hook refused it, so nothing failed.

What Changed

The problem.

  • Orca records a message as pending when it hands it to Codex. Only Codex's echo of the message (the user message carrying Orca's id for it) marks it delivered.
  • When a turn ends completed without that echo, Orca leaves the message pending (codex-structured-turn-end-settlement.ts). Codex echoes every message it records before the turn completes, so normally nothing is lost.
  • A pending message counts as work still owed. The chat, the sidebar and every session list therefore read Working until the Codex process ends or the chat is reopened.
  • A Codex UserPromptSubmit hook that blocks the prompt breaks this. It can block a follow-up steered into a running turn, or the message that opened the turn. Codex records nothing and echoes nothing, and the turn still completes.
  • Codex does report the block. Inside the turn, before turn/completed, it sends a hook/completed frame:
    • the run's eventName is userPromptSubmit and its status is blocked (or stopped);
    • the hook's message to the person, if any, is a warning entry;
    • the reason is a feedback (or stop) entry.
    • Orca dropped that frame.

What you see now.

  • The chat goes idle. It stops reading Working when the turn the hook blocked in completes, and the next message goes out normally.
  • The message itself is drawn as a sent message, on the desktop and the phone: no "not sent", no notice, no Retry. Orca doesn't show the hook's reason. A blocked first message stays where it was sent; a blocked follow-up is drawn where fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710 places any rejected message, at the end of its turn.
  • When the turn ends. A blocked steer ends its turn at Codex's next step boundary: the step in flight and its tools finish first. If another message in the same batch was accepted, the turn goes on and the blocked steer settles when the turn ends.
  • Sidebar. A hook-blocked message leaves the chat reading as idle, not Failed: its row shows the turn it was blocked in. A failed start whose tries ran out still reads Failed.
  • Queued messages. A queued message a hook blocked comes back as a returned card for you to edit, as any refused card does. It is never re-sent into the same hook.
  • Unchanged: a message Codex echoed, a message a Stop interrupted, a turn that completed with no block report, and Claude chats.

The mechanism. It applies to Codex only and persists nothing beyond the message's own record.

  • Reading the report. readCodexPromptBlock (new codex-structured-prompt-block.ts) reads a hook/completed frame for the primary thread whose run is userPromptSubmit with status blocked or stopped.
    • Its reason is the first warning text, then the first feedback or stop text.
  • The reason, kept as a diagnostic, is plain text.
    • Control characters and bidirectional marks, embeddings, overrides and isolates (including U+061C) become spaces, and whitespace collapses to one line.
    • It is cut at 300 UTF-16 units on a whole code point, so an emoji is never split, and ends with an ellipsis.
    • It is kept as text, never as markup, for whatever reads the diagnostic.
  • Keeping it per turn.
    • The adapter notes the block on the Codex echo registry, keyed by that turn (blockPrompt).
    • When several hooks block in one turn, the turn keeps the first message to the person and the first block reason apart, and combines them only when the turn ends. Codex reports hooks in their configured order and stops for the first block reason, and a hook's message to the person is never that reason. So an earlier hook that only spoke to the person never hides a later hook's reason, and a later block with no reason never erases an earlier one.
    • The block lives only until that turn ends, whatever the end was (takePromptBlock). It is bounded like the registry's other per-turn records and cleared with the session.
  • Settling.
    • At that turn's completed end, the existing turn-end settlement settles every send bound to the turn that Codex never echoed. Each is rejected with a new failure kind, hookBlocked, carrying the hook's reason as a diagnostic on the fact. No client shows it.
    • A send whose answer is read after its turn ended is settled the same way, through the existing path for that case.
    • The host's sentence beside the fact is the provider's generic refusal ("The provider did not accept this message."), which only an older client shows.
  • On the client.
    • One shared rule, rejectionDrawnAsSent (a hookBlocked rejection that isn't a queued card's), draws the message as sent: fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat #24710's in-place drawing skips it on the desktop, and the shared projection keeps its row as a plain message on both the desktop and the phone. With no in-place row, no notice or Retry is attached.
    • The desktop does the same with its own copy of the message: when the send's answer already says the hook blocked it, disposeStructuredAgentSessionSendResult lets the copy go as it does for a delivered one, so it never shows as not sent while the row loads. Both clients use the same shared rule, rejectionDrawnAsSent.
    • The phone never hands a blocked message's text back to the composer. When the send's own answer already says the hook blocked it (an answer read after its turn ended), mobileStructuredSendDelivery reports it as sent, so it isn't shown twice, once as a sent message and once as returned draft text.
    • The sidebar's verdict skips it (structured-agent-session-latest-request.ts), so the turn it was blocked in is the verdict.
    • Its classifier verdict stays failure, so a queued card it blocked is returned rather than re-queued.
  • Out of scope: a stall where Codex answers a send but never opens the turn. That is left to fix(native-chat): a Stop binds only the turn it actually stopped #24864's process-end path.

Why

  • Why key on Codex's own report, not on the missing echo? Codex states the block, in the turn. A missing echo alone can't be told apart from a message Codex is about to run: Codex can answer a send, report the previous turn's end and the thread idle, and only then open the new turn and echo it. A test pins that order. The message stays pending until its echo and is delivered.
  • Why draw it as sent? The person's own hook refused it after Codex had it. Nothing in Orca or the agent failed, so a "not sent" mark or a Retry would invite sending the same words into the same hook.
  • Why the Codex adapter? The report and the echo contract are Codex's. Claude is untouched, and its in-doubt pin still passes.

Differences from the common pattern

  • Intended: like the common pattern, Orca doesn't show the hook's reason in the chat. At most it's kept as a hidden diagnostic.

Linked Issue

No issue. Found while investigating a chat stuck on Working after a Stop, during the work on #24864 and #25028.

Visual Proof

After (this PR, head 4279084): a blocked message is drawn as a plain sent message, the chat goes idle, and the sidebar doesn't read Failed. These come from live QA on 2026-10-05: a hidden dev build on a second Mac with an isolated profile, plus the Orca Mobile dev client on an iOS simulator paired to it over LAN. Grok drove the desktop. The phone was driven through Orca's emulator tooling. Codex was a stand-in whose UserPromptSubmit hook blocks any prompt containing "BLOCKME", sending Codex's frames in Codex's real event order: the hook reports blocked, no user message is recorded, and the turn ends.

phone: the opening message blocked by the hook is a plain sent message
Phone: the opening message "Please BLOCKME now" is drawn as a plain sent message, with no reason sentence, no "Not sent" and no Retry. The chat is idle, and the blocked text did not come back into the composer.

phone: a later message goes out and is answered
Phone: a later message goes out and is answered.

desktop: the same chat is plain and idle, and the sidebar row is not Failed
Desktop, same chat: plain and idle. The sidebar row is green, not Failed.

desktop: a blocked steer ends its turn and sits after the turn's status line
Desktop: a follow-up steered into a running turn and blocked by the hook ends that turn at once ("Worked for 1s"). It is drawn after the turn, as a plain message that never gets an answer, because nothing answered it. The chat goes idle and the sidebar row stays not Failed. A 50 ms screen sampler saw no reason sentence, "not sent", Retry or duplicate at any sample.

phone: the same blocked steer
Phone: the blocked steer appears in the same place.

desktop: a message after the blocked steer is answered
Desktop: the next message goes out and is answered.

Unrelated to this PR: the desktop shots show main's notice "Orca now runs Codex without its shared server…".

Before (main, at 182c1a4, before #24710 was merged; not re-run on current main): stuck on Working.

main: a hook-blocked prompt leaves the chat Working with the Stop button
main: the blocked prompt leaves the chat on Working, with Stop.

main: the next message goes out but the chat stays Working
main: the next message goes out, but the chat stays on Working.

main: a blocked follow-up looks sent and the chat stays Working
main: a blocked follow-up looks sent, and the chat stays on Working.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New host-level tests in src/main/runtime/structured-agent-session-codex-hook-blocked-send.test.ts. They use the shipped runtime, host, journal and Codex adapter; only the Codex child is a fake.

  • A hook-blocked steer, with a reason and no echo, in a turn that completes:

    • it settles hookBlocked, the hook's reason kept on the fact;
    • the host's sentence is the generic refusal, never the hook's reason;
    • the chat stops reading Working, its sidebar row is idle and not Failed, and the next message opens a turn.
  • A blocked steer and an accepted steer in one batch: the turn runs on, the accepted steer is delivered and answered, and the blocked one stays pending until the turn's end. Then it settles hookBlocked, drawn as sent with no notice or Retry, the sidebar not Failed and the chat idle.

  • A hook-blocked message that opened its turn: the same.

  • A block with no reason (stopped, no entries): no reason kept.

  • A long reason with markup, control characters, bidi characters, U+061C and an emoji right at the cut: kept plain, one line, at most 300 units, no half emoji, ending with "…".

  • Codex's own example (a warning "go-workflow must start from PlanMode" and a stop "prompt blocked"): both kept, warning first.

  • Three hooks blocking in one turn (a reason, another reason, none): the first reason stands.

  • Two hooks in one turn, the first only speaking to the person ("Heads up") and the second blocking with a reason: both kept.

  • No block report (a hook that let the prompt through, and a block in another turn) with a completed turn that never echoed the send: it stays pending, as today.

  • A send whose steer is refused and that falls through to turn/start, with the previous turn's end and the thread idle arriving before the new turn opens: it stays pending and is delivered by its echo.

  • An echoed steer: delivered, unchanged.

  • The host's records, read by the desktop and the phone:

    • the sending desktop lets its copy go once the row is loaded, and admits the next message;
    • every desktop draws the message as sent exactly once, the sender both before and after its outbox lets the copy go, and it is not drawn in place as not sent;
    • the phone draws it as sent.
  • On the desktop, a hook-blocked message is drawn as sent with no notice and no Retry, whether another device sent it or this desktop still holds its copy (structured-agent-session-hook-blocked-message.test.ts).

  • The sidebar reads the turn, not Failed, for a blocked steer and a blocked first message; a failed start whose tries ran out still reads Failed (structured-agent-session-latest-request.test.ts).

  • On the desktop, a send whose answer says its hook blocked it leaves no not-sent copy and no notice before its row loads, and is drawn once as sent after (src/shared/structured-agent-session-send-disposition.test.ts).

  • The phone reports a send whose answer says its hook blocked it as sent, so its text doesn't also come back to the composer (mobile/src/session/mobile-structured-send-delivery.test.ts).

  • The phone's own chat hook draws a blocked message as sent and still hides other rejected messages (mobile/src/session/use-mobile-structured-agent-session-rejected-rows.test.tsx).

  • Ablations, each production change reverted once:

    Change reverted Tests that fail
    Noting the block, letting it settle a completed turn, or the turn end taking it 5 each; letting it settle also fails the mixed-batch test
    The phone reporting a blocked answer as sent the phone delivery test
    The desktop letting a blocked answer's copy go the desktop disposition test
    The plain-text rule the long-reason test
    The code-point cut, or the U+061C strip the long-reason test
    Accepting any hook status the no-block test
    The warning-first reason the Codex example
    First reason wins its test
    The message and the reason kept apart per turn the two-hook test
    Drawing it as sent (the shared rule) the 2 desktop render tests, the host drawing test and the phone test
    The sidebar skipping it 10 tests (latest-request and host tests)
    The earlier "settle at idle" design put back 6 tests, including the refused-steer race

    The echoed-steer test is a guard and passes either way.

  • Desktop and host, after merging fix(native-chat): a message whose delivery is in doubt no longer holds every later send #25028's current head: 813 test files (8,735 tests) pass, covering this PR's tests, fix(native-chat): a message whose delivery is in doubt no longer holds every later send #25028's and fix(native-chat): a failed start is shown on its message and retried, and later messages go ahead #24340's, every Codex adapter and runtime test, and main's native-chat, journal, runtime and shared tests.

  • Phone: 218 test files (2,079 tests) pass.

  • Static checks:

    • tsc for node, web and the phone app, and the phone's test typecheck ratchet;
    • the localization catalog, runtime-catalog, extraction and coverage checks;
    • full-file oxlint and the anti-slop rules on every touched file, and oxfmt;
    • the changed-lines code-quality gate and React Doctor.

Review

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

  • Mixed versions: the host decides this, so it reaches every client with no wire change.
    • hookBlocked is a new failure kind in a persisted row. Every client also gets the host's generic sentence beside it ("The provider did not accept this message."), and that is what an older client shows.
    • A desktop without this PR shows the message as not sent with that sentence. An older desktop that sent it may also offer a Retry, which would be blocked again.
    • A phone without this PR hides it once it is blocked. A phone with it draws it as sent.
  • SSH and remote hosts: the settlement runs on the host that runs Codex and reads only Codex's own frames for that turn. Losing contact settles nothing.
  • Known limits:
    • A send whose answer comes back after the turn it names has already ended, with no report of a block, still waits for its echo or the process end, as today.
    • The block report names no message. If one turn has two blocked steers, both carry the turn's first message and first reason.

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)

@brennanb2025 brennanb2025 changed the title fix(codex): a message Codex accepted but never recorded no longer leaves the chat on Working fix(codex): a message the person's own hook blocked shows the hook's reason and no longer leaves the chat on Working Oct 3, 2026
@brennanb2025 brennanb2025 changed the title fix(codex): a message the person's own hook blocked shows the hook's reason and no longer leaves the chat on Working fix(codex): a message a Codex hook blocked shows the hook's reason and no longer leaves the chat on Working Oct 3, 2026
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live QA: blocked prompt stuck before, fixed here

A Codex UserPromptSubmit hook that blocks a message records nothing and still ends the turn. On the build before this change, that message stays in the chat as if it were sent, and the chat keeps reading Working (Stop in the composer) after the turn is already over. On this pull request the same message shows "A Codex hook blocked this message: policy check. no secrets in prompts." with no Retry, and the chat goes idle.

Both runs used the same stub Codex, the same scratch folder steps, and a hidden isolated app. Build A is ae864cb14cf8d19bdc4c8b7121dea0021c586466. Build B is 182c1a468eee4be4003c664ecfa16bfb66489a6d.

A hello completes and the chat is idle
Before: "hello" completes with "echo: hello", and the chat is idle.

A blocked message leaves the chat Working
Before: "please BLOCKME now" looks sent, with no block reason, and about 5 seconds after the turn ends the sidebar still says Working and the composer shows Stop.

A later message echoes while the chat stays Working
Before: "after block" does go out and echoes, but the chat stays Working with Stop still shown.

A steered block also stays Working
Before: during a long turn, "steer BLOCKME" is a real steer, the turn ends, and the chat is still Working with Stop. The steered line has no block reason and no Retry.

A relaunch is idle and the blocked lines are unexplained
Before: after relaunch the chat is idle (Send, not Stop) and I did not see a Working flash. The blocked lines still look like ordinary sent messages, with no reason.

B hello completes and the chat is idle
Here: "hello" completes with "echo: hello", and the chat is idle.

B shows the hook block and returns to idle
Here: "please BLOCKME now" shows "A Codex hook blocked this message: policy check. no secrets in prompts." No Retry. Sidebar Done, composer Send.

B delivers the next message
Here: "after block" goes out, echoes, and the chat stays idle. The block notice remains on the earlier message.

B steered block shows the same notice and is idle
Here: "steer BLOCKME" during the long turn is a real steer, then shows the same block sentence, no Retry, and the chat is idle (Done / Send).

B relaunch keeps the block notices and stays idle
Here: after relaunch both notices are still there, the chat is idle, and I did not see a Working flash.

…son apart, and a blocked card names the chat's agent
…2025/pending-steer-settles-at-idle

# Conflicts:
#	src/shared/structured-agent-session-message-projection.ts
#	src/shared/structured-agent-session-outbox.ts
…-barrier' into brennanb2025/pending-steer-settles-at-idle

# Conflicts:
#	mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx
#	src/shared/structured-agent-session-failed-start-elsewhere.ts
#	src/shared/structured-agent-session-message-projection.ts
… idle, not Failed

The person's own hook refused it and the reason shows in place: nothing failed, so the turn it was
blocked in is the session's verdict. A failed start whose tries ran out still reads Failed.
…2025/pending-steer-settles-at-idle

# Conflicts:
#	src/main/codex/codex-structured-dispatch-echo.ts
#	src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
…o reason shown

The person's own hook refused it, so nothing failed to show: every client draws it as a sent
message, with no notice and no Retry. The hook's reason stays on the rejection as a diagnostic.
The hook sentences, their catalog keys, the quoting helper move and the agent name on returned
cards go with the words.
@brennanb2025 brennanb2025 changed the title fix(codex): a message a Codex hook blocked shows the hook's reason and no longer leaves the chat on Working fix(codex): a message your Codex hook blocks no longer leaves the chat Working forever Oct 5, 2026
…2025/pending-steer-settles-at-idle

# Conflicts:
#	src/main/codex/codex-structured-dispatch-echo.ts
#	src/main/codex/codex-structured-turn-end-settlement.ts
…n as sent, so its text no longer also returns to the draft
… settles at the turn's end while the turn runs on
…ed it leaves as sent, never flashing as not sent

This branch has not been deployed

No deployments
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