Skip to content

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

Open

Description

@micrenda

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" }
  3. 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):
  • 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.

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    app-serverIssues involving app server protocol or interfaces
    contextIssues related to context management (including compaction)
    on Jun 1, 2026
  2. github-actions commented on Jun 1, 2026

    @github-actions
    Contributor

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

    Powered by Codex Action

  3. changshengjiuan13-eng commented on Jul 14, 2026

    @changshengjiuan13-eng

    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.

  4. axisrow commented on Jul 16, 2026

    @axisrow

    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.

  5. zazula commented on Jul 24, 2026

    @zazula

    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.

  6. tylorsaling-source commented on Jul 28, 2026

    @tylorsaling-source

    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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIapp-serverIssues involving app server protocol or interfacesbugSomething isn't workingcontextIssues related to context management (including compaction)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions