app-server: silent turn/completed(last_agent_message=null) when run_turn early-returns after compaction failure

Open 💬 5 comments Opened Jun 1, 2026 by micrenda
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What issue are you seeing?

Several early-return paths in codex-rs/core/src/session/turn.rs::run_turn make the function return None (which yields a task_complete with last_agent_message=null, zero last_token_usage, and no time_to_first_token_ms), without emitting an EventMsg::Error to the wire/rollout. The companion emit_turn_error_lifecycle call in those paths only notifies in-process extension contributors; tracing::error!() only writes to the local log. JSON-RPC clients connected to a codex app-server --listen see a normal-looking completed turn and cannot tell that the model was never sampled.

From a programmatic client's view, the WebSocket exchange is:

-> turn/start { threadId, input: [...] }
<- turn/started
<- token/count   (total_token_usage.total_tokens == model_context_window if set_token_usage_full has fired previously)
<- token/count
<- turn/completed { turn: { id, status: "completed" },
                    last_agent_message: null,
                    time_to_first_token_ms: null,
                    duration_ms: 2_000-12_000 }

No error event. No item events. Indistinguishable on the wire from a normal completion in which the model voluntarily returned an empty assistant message.

Affected paths (HEAD checkout)

In codex-rs/core/src/session/turn.rs::run_turn:

  • The pre-sampling compact branch around L147-L162 (catching run_pre_sampling_compact Err) calls emit_turn_error_lifecycle + tracing::error!("Failed to run pre-sampling compact") then return None;. It does not call sess.send_event(turn_context, EventMsg::Error(...)).
  • The mid-turn auto-compact branch (auto-compact after a sampling result, ~L290-L316) follows the same pattern: extension lifecycle + tracing log + return None;, no wire-visible event.

By contrast, the existing catch-all at the bottom of the main loop (~L394-L411) does emit the wire-visible event:

Err(e) => {
    info!("Turn error: {e:#}");
    let error = e.to_codex_protocol_error();
    sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()).await;
    if error == CodexErrorInfo::UsageLimitExceeded { /* ... */ }
    let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None));
    sess.send_event(&turn_context, event).await;
    break;
}

And codex-rs/core/src/compact.rs:242-244 follows the same convention before returning Err(ContextWindowExceeded). So the established pattern across the codebase is "emit EventMsg::Error before failing the turn" — the two paths above just slipped through.

What steps can reproduce the bug?

  1. Bring up a codex app-server --listen unix:///tmp/codex.sock whose persistent session is in the "context-full" state, i.e. one of its prior turns triggered Session::set_total_tokens_full (either via the ContextWindowExceeded branch in try_run_sampling_request at core/src/session/turn.rs:985-988, or via auto-compact exhaustion in core/src/compact.rs:242). After that fires once, state.history.token_info is pinned at total_tokens == model_context_window until a successful compaction resets it.
  2. From any JSON-RPC client (e.g. a small Python WebSocket script or websocat), drive:
  • initialize
  • thread/resume { "threadId": "<id>", "excludeTurns": true, "persistExtendedHistory": true }
  • turn/start { "threadId": "<id>", "input": [{ "type": "text", "text": "<<no reply needed>>", "text_elements": [] }], "approvalPolicy": "on-request" }
  1. Read frames until turn/completed for the new turn_id.

Observed: turn/completed with turn.status = "completed", last_agent_message = null, all-zero last_token_usage, no error event in between. The rollout JSONL records task_started, turn_context, the response_item for the user message, the user_message event, two token_count events (with the synthetic total_token_usage.total_tokens == model_context_window), then task_complete. Nothing else.

Observation that surfaced this

On a long-lived persistent session (open for several weeks, ~85k rollout lines, ~138MB):

  • Several consecutive turn/start requests to the app-server over multiple hours produced silent task_complete(null) turns (durations 2.1 s - 11.4 s, no ttft, no Error).
  • Recovery happened only when a user typed input into a separate codex resume --last process attached to the same thread. That second process has its own in-memory Session and apparently completed a compaction successfully; subsequent turns through it sampled normally.
  • The app-server's in-memory session state remained context-full for the entire failing window. Rate limits were fine (primary 2%, secondary 13%, rate_limit_reached_type: null).

What is the expected behavior?

When run_turn is going to return None because of a pre-sampling or mid-turn compaction failure (or any path where the model was not actually invoked), the app-server should emit a wire-visible EventMsg::Error (or a new variant such as EventMsg::TurnFailed) before the task_complete, so a JSON-RPC client can detect that the turn was not sampled. The task_complete itself could additionally carry a richer status than "completed" in those cases (e.g. "aborted"/"failed"/"context_full"), but even the simpler fix — propagating the existing EventMsg::Error — would close the observability gap.

Minimal illustrative patch

Aligns the two missing paths to the convention already used at turn.rs:394-411 and compact.rs:242-244:

// core/src/session/turn.rs - pre-sampling compact branch
if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
    let error = err.to_codex_protocol_error();
    sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()).await;
    if error == CodexErrorInfo::UsageLimitExceeded
        && let Err(e2) = sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
            turn_context: turn_context.as_ref(),
        }).await
    {
        warn!("failed to usage-limit active goal after usage-limit error: {e2}");
    }
+   let event = EventMsg::Error(err.to_error_event(/*message_prefix*/ None));
+   sess.send_event(&turn_context, event).await;
    error!("Failed to run pre-sampling compact");
    return None;
}

And the analogous insertion in the mid-turn run_auto_compact failure branch a few hundred lines lower. Both should adopt the e.to_codex_protocol_error() + e.to_error_event(None) pair already in use at L394-L411 (ownership of err allows both calls, same as the existing pattern).

Additional information

  • Why this is a bug and not a feature: extension contributors and tracing logs are not a substitute for the JSON-RPC surface. Any client that consumes codex app-server programmatically — IDE integrations, codex exec, our JSON-RPC dispatcher — has no robust way today to distinguish "model produced an empty response" from "model was never invoked". The current behavior also disagrees with itself: the catch-all path at L394 emits EventMsg::Error; the two paths above don't. Two-thirds of the same handler family emits an observable failure signal; one-third silently swallows it.
  • Related issues (similar symptom, different surface or root cause):
  • #24536 - codex exec can silently complete empty when configured MCP tools are deferred behind tool_search (same observable from a different cause; a generic wire-level failure signal would help diagnostics there too).
  • #16872 - app-server websocket shared-thread turns complete but rollout never materializes (adjacent symptom in the same area).
  • #7808 - Running out of room in the Codex context window is immediately fatal to that chat thread (the human-facing UX of the same underlying state).
  • Environment:
  • Codex binary: codex 0.125.0 was the persistent app-server at the time of the observation (running as a systemd unit, codex app-server --listen unix:///run/.../app.sock); the interactive sibling was codex 0.134.0 via codex resume --last. Source examined: HEAD of https://github.com/openai/codex.git.
  • OS: Linux (Debian, Linux 6.12.85+deb13-amd64).
  • Protocol used by the client: v2 JSON-RPC over Unix-socket WebSocket (the client does a manual HTTP Upgrade handshake).
  • Investigation context: this was found while hardening our JSON-RPC dispatcher against the symptom. The full investigation notes (with the per-turn event sequences and code-level tracing) are local; happy to attach a sanitized excerpt if useful. The client-side fix is independent (it will start to declare processed=false based on last_agent_message, last_token_usage, item-event count, and the very EventMsg::Error this issue proposes to emit), but a wire-level signal from Codex would let every programmatic client benefit, not just ours.
  • Not asking for: a redesign of the context-full state machine. Even keeping the current pinned token_info after set_total_tokens_full, just emitting EventMsg::Error (or equivalent) on the early-return paths would already make this diagnosable and correctable from the client side.

View original on GitHub ↗

5 Comments

github-actions[bot] contributor · 2 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #24732

Powered by Codex Action

changshengjiuan13-eng · 1 month ago

Additional related failure path at rust-v0.144.0-alpha.4 / commit 049586f41571e74b44c841868bca3a2233214a71.

This is adjacent to the silent last_agent_message=null behavior described here, but the discriminator is that sampling did start (time_to_first_token_ms is present) and the stream later failed before a completed assistant message existed.

Vulnerable lifecycle

  1. regular turn starts sampling;
  2. reasoning/tool output may already be persisted;
  3. upstream stream fails before a completed assistant message;
  4. compatibility behavior lets run_turn return Ok(None);
  5. on_task_finished emits a normal completion with last_agent_message=null.

Narrow recovery rule

Convert only this combination to an interrupted turn:

TaskKind::Regular
AND task_result == Ok(None)
AND time_to_first_token_ms is present

Review/compact tasks and truly empty completions which never started sampling remain unchanged. A companion carry-forward change preserves the last valid assistant message across tool follow-up sampling.

The patch also normalizes legacy mid-stream error envelope variants into the same typed API errors used by response.failed, so a terminal upstream error does not get retried as a generic stream close.

Sanitized source patch, focused test coverage, and reproducible workflow:

https://github.com/changshengjiuan13-eng/codex-desktop-iab-null-tail-recovery

The repository contains no Desktop binary, auth material, provider configuration, rollout, or user data.

axisrow · 1 month ago

Confirming on codex-cli 0.144.4 (app-server --listen) — this is the class of "app-server swallows the failure and the wire client can't tell." From a programmatic consumer's side the broken contract is the same across both paths now on the table:

  1. pre/mid-compact early-return (original report) — model never sampled, time_to_first_token_ms: null, normal turn/completed, no error event.
  2. post-sampling stream failure (@changshengjiuan13-eng, 0.144.0-alpha.4) — sampling did start (time_to_first_token_ms present), stream died, still Ok(None) → normal completion.

In both, a JSON-RPC/WS client sees an indistinguishable-from-success turn/completed{last_agent_message:null} and only a local tracing::error! — nothing on the wire/rollout. That's the core diagnostic gap: the only place the failure is logged is the server's own stderr, which a brokered/embedded client has no access to. A long-lived app-server consumer (companion plugin, review harness) literally cannot tell the model was never sampled vs. returned empty.

+1 on emitting EventMsg::Error on these paths (matching the existing catch-all in run_turn) as the minimal fix; the narrower "interrupted turn" recovery rule for the post-sampling case is the right complement, not a substitute, since it still needs a wire-visible signal to act on. Tying this to #28810 (OTLP logs empty) — observability-wise these silent failures are exactly the ones that most need an on-the-wire event, because the local log channel is precisely what the client can't see.

zazula · 1 month ago

Confirmed on codex-cli 0.145.0, macOS VirtualBuddy guest, authenticated subscription app-server over WebSocket, model gpt-5.3-codex-spark.

A bridge-controlled durable implementation lane reproduced the silent-success contract twice on the same thread:

  • First turn: status=completed, error=null, item sequence only [userMessage], no agentMessage.
  • Recovery turn: status=completed, error=null, item sequence [userMessage, contextCompaction, contextCompaction, webSearch, contextCompaction, contextCompaction, contextCompaction], no agentMessage.

The worker did mutate its isolated worktree before each silent completion, so a controller cannot safely interpret either result as success or failure. The second turn left a 1,045-line syntactically valid but uncommitted partial module. There was no terminal JSON receipt and no wire-visible error. We abandoned the context-heavy thread and launched a fresh one-shot thread to recover.

This is especially hazardous for app-server brokers/durable-worker controllers: turn.status=completed currently satisfies the obvious terminal predicate, but there is no evidence that the assignment completed, tests ran, or a final result was produced. Client-side mitigation is to classify completed + no agentMessage as empty_terminal_failure, never success, and require a fresh thread after repeated compaction items. That mitigation still cannot identify the underlying reason without the EventMsg::Error proposed here.

Sanitized identifiers available if useful:

  • failing thread 019f956b-a0f3-7b40-bdf8-d32fda58f335
  • turns 019f956b-a171-7792-bcfa-2bb5c9806bd0, 019f956e-8b11-7010-9dfb-3fde21e9cec3

Acceptance should include a regression asserting any early-return/stream-failure path that produces no agent message cannot emit a success-looking turn/completed(status="completed") without a preceding wire-visible error or a failure/aborted terminal status.

<!-- agent-id: c_zazula:p_email-plane:h_agent-script-test:r_controller:u_root -->

tylorsaling-source · 1 month ago

Adjacent measured case showing the same observability failure shape through a different root cause.

On Windows Desktop 26.721.4979.0, a long-running turn ended with:

type: task_complete
turn_id: 019fa62c-0b6f-7bf0-ba34-e09aad4b8277
last_agent_message: null
error.message: Selected model is at capacity. Please try a different model.
error.codex_error_info: server_overloaded
duration_ms: 1501434

Unlike this issue's compaction-error path, the error object existed inside task_complete; however, the Desktop client still showed no explanatory banner, transcript message, or notification—only a red failure icon. The user had to inspect the rollout file to learn why the turn stopped.

This suggests the observable contract should cover every early/terminal path:

  • a completion carrying an error must not be rendered as normal completion;
  • last_agent_message: null plus an error must produce a visible failed/paused state;
  • unhandled model or tool-call failures must reach the UI, not remain only in internal records;
  • retryable errors should pause/retry rather than hand back silently;
  • the UI should offer retry, model switch, or cancel while preserving task and tool state.