Skip to content

feat(codex): real-time mid-turn input via app-server turn/steer#94

Draft
juacker wants to merge 6 commits into
mainfrom
feat/codex-app-server-steering
Draft

feat(codex): real-time mid-turn input via app-server turn/steer#94
juacker wants to merge 6 commits into
mainfrom
feat/codex-app-server-steering

Conversation

@juacker

@juacker juacker commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

What

Add real-time mid-turn input for Codex, the way you asked for: instead of the Claude-style stop-and-restart, Codex now runs over the codex app-server JSON-RPC transport and injects queued messages into the live turn via turn/steer — no interrupt, no restart.

Gated behind CLAI_CODEX_APP_SERVER (default off); codex exec stays the default transport until this is validated live.

Why app-server

codex exec consumes stdin once and closes it, so mid-turn input is impossible without killing the process. codex app-server is the JSON-RPC protocol every first-party Codex surface (VS Code, desktop, web) runs on, and it exposes turn/steer — input into the currently active turn, guarded by an expectedTurnId precondition. That's the clean primitive for this feature.

Commits (staged for commit-by-commit review)

  1. feat(codex): app-server JSON-RPC transport client — self-contained codex_app_server.rs: process lifecycle, newline-delimited JSON-RPC framing (background stdout reader → channel), pure request/notification/error builders + unit tests. Not wired in yet.
  2. feat(codex): run turns through app-server when enabled — dispatch flag + run_codex_turn_app_server: initialize → thread/start|resume → turn/start handshake, then maps the notification stream onto the existing CodexStreamState + handle_codex_item by normalizing app-server items into the exec item shape (so tool-call persistence, streaming, usage all reuse the proven path). approvalPolicy=never + sandbox=danger-full-access mirror the exec --dangerously-bypass-approvals-and-sandbox; structured error codes (contextWindowExceeded/usageLimitExceeded) are folded into the CLI error classifiers so context/usage recovery still fires. Behaviour-parity, no steering yet.
  3. feat(codex): steer queued messages into the live turn — the payoff. A 400ms poll injects pending queued messages' text via turn/steer while a turn is active. Delivery is confirmed on the steer response: accepted → mark delivered + QueuedMessagesDelivered; rejected (turn ended / id mismatch) → leave queued so the existing followup-run path delivers them (no message loss). Image-bearing messages are deferred to the followup run (which resolves image paths); steering carries text only. In-flight tracking prevents double-send before the response lands.
  4. test(codex): …normalization + document flag — pure-function unit tests for the mapping layer + module rustdoc "Enabling / testing" note.

Verification

  • cargo fmt --check, cargo clippy --lib -D warnings, cargo test --lib740 pass (incl. 7 transport builder/classification tests + 3 normalization/usage tests).
  • Protocol shapes confirmed live against Codex CLI 0.142.5: initialize, thread/start, turn/started (turn id), item/completed, thread/tokenUsage/updated, error (structured codexErrorInfo), turn/completed.

Honest caveats (please test before merge)

  • The Codex account this was developed against is out of credits (usageLimitExceeded), so a successful multi-turn/tool-call/steer flow was not exercised end-to-end here — only the envelope + failure paths. Please run with a funded Codex login: (1) a normal turn (tool calls, agent message), (2) send a message mid-turn and confirm it's absorbed without restart (log: "Steered queued user message(s) into the live Codex turn"), (3) resume across turns.
  • MCP wiring is faithful to the exec -c mcp_servers.clai.* flags but couldn't be fully smoke-tested from the sandbox; the clai tools working under app-server is the key thing to confirm.
  • Enable with CLAI_CODEX_APP_SERVER=1. Flipping the default is a deliberate follow-up once you've run it.

Backend only; no bindings/frontend changes.

juacker added 6 commits July 4, 2026 21:11
First stage of the Codex app-server migration. Adds a self-contained
transport module: process lifecycle for a long-lived `codex app-server`
child, newline-delimited JSON-RPC framing (background stdout reader ->
channel), and pure request/notification/error builders with unit tests.

Not wired into the run path yet (that is the next commit); gated behind
the CLAI_CODEX_APP_SERVER env flag so `codex exec` stays the default
transport. app-server is the protocol every first-party Codex surface
runs on and exposes turn/steer for real-time mid-turn input.
Second stage: wire the app-server transport into the Codex run path,
behaviour-parity with codex exec (no steering yet). When
CLAI_CODEX_APP_SERVER is set, dispatch routes to run_codex_turn_app_server,
which does the initialize -> thread/start|resume -> turn/start handshake,
then maps the notification stream (item/started, item/completed,
thread/tokenUsage/updated, error, turn/completed) onto the existing
CodexStreamState + handle_codex_item helpers by normalizing app-server
items into the exec item shape. approvalPolicy=never + danger-full-access
mirror the exec bypass; structured error codes are folded into the CLI
error classifiers so context/usage recovery still fires. codex exec
remains the default.

turn_steer_request carries a temporary allow(dead_code) until the next
commit wires steering.
Third stage: real-time mid-turn input via turn/steer — the payoff over
the exec stop-and-restart model. A 400ms poll in the app-server driver
reads pending queued messages for this connection while a turn is active
and injects their text into the running turn with turn/steer, guarded by
the active expectedTurnId. Delivery is confirmed on the steer response:
accepted -> mark delivered + emit QueuedMessagesDelivered; rejected (turn
ended / id mismatch) -> leave queued so the existing followup-run path
delivers them. Image-bearing messages are deferred to the followup run
(which resolves image paths). In-flight tracking prevents double-sending
before the response lands.
Fourth stage: unit tests for the pure mapping layer (normalize_app_server_item
discriminant mapping, reasoning content/summary flattening, token-usage
breakdown -> RunUsage) and an 'Enabling / testing' note in the module
rustdoc (repo convention documents env flags in code, not markdown). Default
stays codex exec; flipping the default is deferred until the app-server path
is validated live.
Addresses the validated findings from the external review of this PR:

- aps_request_await now bounds each handshake request (initialize,
  thread/start|resume) with a 30s deadline, so a wedged `codex
  app-server` fails fast with a clear message instead of hanging the
  run until the user cancels.
- Stdout closing without `turn/completed` is now surfaced as a run
  failure instead of finalizing a silently truncated turn as success
  (found while verifying the review; partial parts are still persisted).
- Regression test: agentMessage without a `text` field normalizes to an
  explicit null in the exec shape.
- Stale docstring fixed (steering is in this PR, not a follow-up) and
  the per-turn/best-effort lifetime of the steer bookkeeping documented.

Rejected from the review (verified against the code): the two "major"
findings assume pending_steer outlives the turn; it is a local of
run_codex_turn_app_server and turn/completed breaks the loop, so it
lives exactly one turn and ids cannot collide across turns.
Two issues found running the app-server branch (workspace test session):

1. SECURITY (regression vs exec): the app-server thread/start+resume set
   approvalPolicy=never + sandbox=danger-full-access but left Codex's
   built-in shell_tool ENABLED, so Codex ran shell commands through its
   own native path (observed: pwd/date/ls/python), bypassing CLAI's
   permission checks and bwrap sandbox entirely. The exec path passes
   `--disable shell_tool` for exactly this reason. Fix: the thread config
   now sets `features.shell_tool=false` (the documented equivalent of
   `--disable shell_tool`) on both start and resume, so Codex has no
   unmediated execution path — every command routes through the gated
   MCP `bash_exec`. thread/resume now also re-applies the full config
   (each turn spawns a fresh app-server process, so it must be re-set).

2. ORDERING: the whole turn accreted into one assistant message whose
   created_at is frozen at turn start, so a mid-run (steered) user
   message — created when the user hit send — sorted AFTER the entire
   reply, showing the handling of a comment before the comment itself.
   Fix: on steer accept, close the current assistant message and open a
   fresh one (split_codex_assistant_message), yielding the natural order
   pre-steer output -> user message -> post-steer output. An empty
   leading placeholder is dropped instead of left as an empty bubble.

Tests: thread_start/thread_resume now assert features.shell_tool=false;
full lib suite 742/742, clippy -D warnings clean.
@juacker juacker closed this Jul 12, 2026
@juacker juacker reopened this Jul 12, 2026
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