`response.incomplete` is treated as a retryable stream failure, causing unnecessary retries and transport fallback

Open 💬 1 comment Opened Aug 16, 2026 by starriet9

What version of Codex CLI is running?

Official rust-v0.147.0 at be6e8eac029b183056b7e4402879f15d2c85f61b. I reproduced the bug in a clean detached worktree of that tag with a deterministic codex-api mock-SSE test. The same affected handling is present in current main at 73abda8bfef6bd42eb11351be53980a027fd1feb.

What subscription do you have?

Pro

Which model were you using?

gpt-5.6-sol. The handling bug is model-independent.

What platform is your computer?

Microsoft Windows NT 10.0.19045.0 x64

What terminal emulator and version are you using (if applicable)?

Windows Terminal 1.24.11911.0 with PowerShell 7.6.4. The behavior is in the Responses event handling path and is terminal-independent.

Codex doctor report

Not included because this has a deterministic mock-SSE reproduction and does not depend on installation, authentication, terminal, MCP, or network configuration.

What issue are you seeing?

When the provider cleanly ends a Responses stream with a response.incomplete event, Codex converts it to ApiError::Stream. For example:

{
  "type": "response.incomplete",
  "response": {
    "status": "incomplete",
    "incomplete_details": {
      "reason": "max_output_tokens"
    },
    "usage": {
      "input_tokens": 120,
      "output_tokens": 30,
      "total_tokens": 150
    }
  }
}

This is a semantic terminal event from the provider, not a dropped network stream. However, mapping it to ApiError::Stream makes the session loop treat it as retryable. After the retry budget is exhausted, the same classification can also trigger the WebSocket-to-HTTPS fallback:

Falling back from WebSockets to HTTPS transport. stream disconnected before completion: Incomplete response returned, reason: max_output_tokens

stream disconnected before completion: Incomplete response returned, reason: max_output_tokens

Changing transports cannot fix max_output_tokens or content_filter, and repeating the same request can add latency and usage. The parser also discards the usage block carried by response.incomplete, leaving session token accounting stale.

What steps can reproduce the bug?

  1. Configure a mock Responses endpoint to return an SSE response.incomplete event with incomplete_details.reason = "max_output_tokens" and a usage block.
  2. Set stream_max_retries to a value greater than zero.
  3. Start a turn against the mock endpoint.
  4. Observe that the event becomes a retryable stream error rather than a terminal incomplete response. The retry path is entered and the supplied usage is not recorded.

The same behavior follows directly from these current paths:

  • codex-rs/codex-api/src/sse/responses.rs: response.incomplete is converted to ApiError::Stream.
  • codex-rs/codex-api/src/api_bridge.rs: ApiError::Stream becomes CodexErr::Stream.
  • codex-rs/protocol/src/error.rs: stream errors are retryable.
  • codex-rs/core/src/responses_retry.rs: retry exhaustion can switch from WebSockets to HTTPS.

The existing incomplete_response_emits_content_filter_error_message integration test sets stream_max_retries = 0, so it verifies the displayed error but cannot detect that the event is incorrectly classified as retryable. Setting retries to 2 and asserting that the mock receives exactly one request exposes the classification bug.

What is the expected behavior?

response.incomplete should remain distinct from transport failure. Codex should:

  • preserve incomplete_details.reason;
  • preserve and record the event's usage block when present;
  • surface a clear terminal error for reasons such as max_output_tokens and content_filter;
  • avoid stream retries and transport fallback for this semantic terminal event.

Automatic continuation is a separate policy question because blindly replaying a partially completed response can duplicate tool side effects.

Additional information

The focused repair is to add a dedicated ResponseIncomplete API/protocol error carrying the reason and optional token usage, classify it as non-retryable, and update session accounting before returning it. If usage is absent, Codex can recompute the local context estimate rather than leaving the last count unchanged.

Candidate implementation:

I implemented and tested this candidate repair locally against main. The candidate branch is based on current main at 73abda8bfef6bd42eb11351be53980a027fd1feb; none of the affected files changed between the tested revision and that commit:

  • a characterization test in a clean official rust-v0.147.0 worktree confirmed that a response.incomplete event carrying usage becomes a retryable ApiError::Stream;
  • 168 codex-api tests passed;
  • 273 codex-protocol tests passed;
  • 3 codex-response-debug-context tests passed;
  • focused integration tests verify that content_filter and max_output_tokens are terminal and non-retryable and each makes exactly one request;
  • the max_output_tokens integration test verifies that usage is preserved, and both cases produce a clear reason-specific error;
  • Clippy passed for all changed packages.

Related but distinct:

  • #14753 reports the max_output_tokens symptom but was closed without a root-cause fix.
  • #37138 covers missing token accounting when usage is absent and notes that response.incomplete usage is discarded, but it does not cover the retry and transport-fallback misclassification.
  • #11558 introduced the generic response.incomplete handling that currently maps the event to a stream error.

View original on GitHub ↗

1 Comment

jdcodes1 · 11 days ago

Confirmed on current main (1f41cc5d92) — the mapping and both downstream symptoms check out, and the surrounding code makes the fix shape fairly clear.

The mapping. response.incomplete extracts only incomplete_details.reason and returns ApiError::Stream:

https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/codex-api/src/sse/responses.rs#L453-L463

Two things are notable in context:

  1. It's the least-differentiated classification in the file. The response.failed arm directly above it carefully fans out into fatal (MisalignmentPolicyViolation), non-retryable (InvalidRequest), ServerOverloaded, and Retryable { delay } — while response.incomplete, which is a successful terminal status of the Responses API (the server completed the stream and told you why it stopped), gets the same treatment as a dropped TCP connection.
  2. The usage block really is discarded. The arm never touches event.response.usage, unlike response.completed (L464-L481), which parses ResponseCompleted and forwards token_usage. So the tokens billed for the truncated response never reach session accounting — your stale-accounting observation is structural, not incidental.

Why retries and the transport fallback follow. Once the error is a stream-class failure, the retry machinery has no way to distinguish it: handle_retryable_response_stream_error retries up to stream_max_retries and then, for any error it's handed, tries the WebSocket→HTTPS transport switch (core/src/responses_retry.rs#L86-L100 — the exact "Falling back from WebSockets to HTTPS transport. {err}" message you captured). Classification is the only gate; there is no semantic check at the fallback site. Retrying an identical request against max_output_tokens can only reproduce the condition, and against content_filter it re-submits flagged content.

Fix shape that falls out of the existing structure:

  1. Parse the response object in the incomplete arm the same way response.completed does, and emit a terminal event (either ResponseEvent::Completed with an incompleteness marker, or a dedicated ResponseEvent::Incomplete { reason, token_usage }). That immediately restores usage accounting.
  2. Let the turn loop handle the reason semantically: max_output_tokens is the same condition the auto-compaction/token-limit machinery already exists for — surfacing "response truncated (max_output_tokens)" and/or triggering compaction is coherent; content_filter should be a non-retryable user-visible error like InvalidRequest.
  3. If a conservative intermediate step is preferred: classify incomplete as a non-retryable ApiError variant so the retry loop and transport fallback are skipped, even before the richer terminal-event handling lands. That alone eliminates the wasted retries/fallback and the misleading "stream disconnected" framing.

Your mock-SSE repro would convert directly into the regression test for whichever variant lands: assert zero retries, no transport fallback, and non-stale usage after an incomplete event with a usage block.