`response.incomplete` is treated as a retryable stream failure, causing unnecessary retries and transport fallback
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?
- Configure a mock Responses endpoint to return an SSE
response.incompleteevent withincomplete_details.reason = "max_output_tokens"and a usage block. - Set
stream_max_retriesto a value greater than zero. - Start a turn against the mock endpoint.
- 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.incompleteis converted toApiError::Stream.codex-rs/codex-api/src/api_bridge.rs:ApiError::StreambecomesCodexErr::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_tokensandcontent_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:
- Branch: https://github.com/starriet9/codex/tree/fix/response-incomplete-terminal
- Commit: https://github.com/starriet9/codex/commit/e556049f66f09a318fcf73a92979941b334e5e29
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.0worktree confirmed that aresponse.incompleteevent carrying usage becomes a retryableApiError::Stream; - 168
codex-apitests passed; - 273
codex-protocoltests passed; - 3
codex-response-debug-contexttests passed; - focused integration tests verify that
content_filterandmax_output_tokensare terminal and non-retryable and each makes exactly one request; - the
max_output_tokensintegration 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_tokenssymptom but was closed without a root-cause fix. - #37138 covers missing token accounting when usage is absent and notes that
response.incompleteusage is discarded, but it does not cover the retry and transport-fallback misclassification. - #11558 introduced the generic
response.incompletehandling that currently maps the event to a stream error.
1 Comment
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.incompleteextracts onlyincomplete_details.reasonand returnsApiError::Stream:https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/codex-api/src/sse/responses.rs#L453-L463
Two things are notable in context:
response.failedarm directly above it carefully fans out into fatal (MisalignmentPolicyViolation), non-retryable (InvalidRequest),ServerOverloaded, andRetryable { delay }— whileresponse.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.usageblock really is discarded. The arm never touchesevent.response.usage, unlikeresponse.completed(L464-L481), which parsesResponseCompletedand forwardstoken_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_errorretries up tostream_max_retriesand 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 againstmax_output_tokenscan only reproduce the condition, and againstcontent_filterit re-submits flagged content.Fix shape that falls out of the existing structure:
responseobject in theincompletearm the same wayresponse.completeddoes, and emit a terminal event (eitherResponseEvent::Completedwith an incompleteness marker, or a dedicatedResponseEvent::Incomplete { reason, token_usage }). That immediately restores usage accounting.max_output_tokensis 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_filtershould be a non-retryable user-visible error likeInvalidRequest.incompleteas a non-retryableApiErrorvariant 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
incompleteevent with a usage block.