Skip to content

fix(codex): unify live stream commit and terminal semantics - #103

Open
mulfyx wants to merge 2 commits into
raine:mainfrom
mulfyx:fix/codex-stream-state-semantics
Open

fix(codex): unify live stream commit and terminal semantics#103
mulfyx wants to merge 2 commits into
raine:mainfrom
mulfyx:fix/codex-stream-state-semantics

Conversation

@mulfyx

@mulfyx mulfyx commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • classify Codex stream events once and reuse the result across HTTP, WebSocket, Messages, and Chat Completions paths;
  • keep structural response.output_item.added frames private until actual text, reasoning, tool arguments, or a finalized tool item makes replay unsafe;
  • keep native Codex strict for every response.incomplete, while allowing only OpenCode Go Responses to translate the exact max_output_tokens terminal into Claude's normal max_tokens stop;
  • retry HTTP body truncation and WebSocket disconnects only before that semantic commit point, without leaking stale blocks from the abandoned attempt;
  • treat response.failed and response.incomplete as typed failures instead of successful completion, while preserving permanent policy, quota, permission, and context-window errors;
  • keep codex.rate_limits as telemetry while the response stream continues;
  • stop converting an HTTP body failure after a completed tool item into a synthetic successful message_stop; the existing WebSocket missing-terminal compatibility path remains intact.

Reproduced failures

The regression fixtures use raw TCP/SSE and WebSocket upstreams, including an inflated Content-Length followed by an early socket close.

Scenario Before With this change
structural message item, then HTTP body truncation 502, no replay second upstream attempt succeeds; one downstream block
structural function-call item, then HTTP body truncation one attempt followed by event: error second upstream attempt succeeds; abandoned tool ID/name never leak
WebSocket closes after structural function-call item retry window is already closed full-context retry succeeds; abandoned structural frames are discarded
Codex response.incomplete after partial text normal max_tokens completion Anthropic event: error, without synthetic message_stop
OpenCode Go Responses response.incomplete/max_output_tokens regressed to 502 after the shared Codex fix normal stop_reason=max_tokens plus message_stop; other incomplete reasons remain errors
permanent policy failure collapsed to 502 api_error original client status/type/message are preserved
code-only context/quota/permission failures retried or collapsed to 502 413 request_too_large, 429 rate_limit_error, or 403 permission_error
codex.rate_limits followed by a valid completion premature 429 telemetry is ignored for terminal-state purposes and completion succeeds
completed tool item, then HTTP Decode/body error synthetic successful tool_use completion partial stream ends with event: error

Why these boundaries

Pinned native Codex rust-v0.146.0 (e363b08c) treats OutputItemAdded as stream-state initialization. Tool execution is queued only from the finalized OutputItemDone: turn lifecycle, tool queue. That makes an added-only item safe to discard and replay as long as no semantic output has been exposed downstream.

The same Codex parser returns errors for both response.failed and every response.incomplete; only response.completed becomes a successful completion. It also gives special fatal handling to context-window, quota, usage, cyber-policy, invalid_prompt, and bio_policy codes: Responses event parser, retry classification.

That strict policy is provider-scoped. OpenCode Go's Responses-compatible lane can legitimately terminate with response.incomplete and incomplete_details.reason=max_output_tokens; the opt-in is therefore limited to that exact event/status/reason tuple. Missing, malformed, content_filter, duplicate, and post-terminal events still fail. OpenAI documents response.incomplete as the terminal event for an incomplete response and exposes incomplete_details on the response object: Responses streaming event, Responses object.

Codex forwards rate-limit snapshots as ResponseEvent::RateLimits and continues consuming the response instead of treating the snapshot itself as a terminal API error: WebSocket reader, turn handling.

CCP pins reqwest 0.12.28. Its response-body APIs wrap underlying body-stream failures as Decode, which is why a truncated HTTP response can surface as error decoding response body rather than is_body(): body implementation, error classification and source chain, Decode construction.

Anthropic streams distinguish normal message_stop completion from an in-stream event: error; the SDK raises on the latter instead of synthesizing success. Transparent retry after already-yielded semantic output is also intentionally avoided because it can duplicate content: streaming contract, Python SDK parser, maintainer clarification.

Compatibility notes

  • This refines the semantic-output retry boundary introduced in #51 and complements Decode/body-error handling from #100.
  • For the Codex provider, it intentionally replaces the response.incomplete -> max_tokens choice retained in #70 / #71, because the pinned native Codex parser treats every incomplete response as an error. OpenCode Go Responses opts into only the exact max_output_tokens case, without weakening Codex semantics.
  • The v0.1.3 WebSocket compatibility behavior for a fully closed tool call followed only by a missing terminal frame is preserved. HTTP body errors and explicit terminal failures are no longer eligible for that success synthesis.

Verification

  • pre-fix regression runs reproduced the failures in the table above;
  • cargo test --offline --all-targets --quiet — 987 passed, 0 failed;
  • cargo +1.96.0 clippy --offline --all-targets -- -D warnings;
  • cargo +1.96.0 fmt --all -- --check;
  • cargo build --release --locked --offline;
  • release binary health check on a non-default port;
  • live post-rebase Codex tool/image/classifier canary (gpt-5.4) passed;
  • live OpenCode Go Responses canary observed upstream response.incomplete/max_output_tokens and downstream stop_reason=max_tokens plus message_stop, without event:error;
  • live OpenCode Go Messages canary (qwen3.8-max) completed Read, Edit, Bash/classifier, thinking, and PNG input through the rebased branch.

@mulfyx
mulfyx marked this pull request as draft August 8, 2026 17:08
@mulfyx
mulfyx force-pushed the fix/codex-stream-state-semantics branch from b0bc4fb to ed725c9 Compare August 8, 2026 19:44
@mulfyx

mulfyx commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased this PR onto current main (4ea0414), which includes the WebSocket keepalive work from 7075c6b and the OpenCode Go catalog update from #102.

The keepalive and stream-state changes are complementary. The single textual conflict was in adjacent tests; the rebased branch preserves both the keepalive retry coverage and the shared terminal/failure classifier coverage.

I also fixed the cross-provider regression found during review:

  • native Codex remains strict for every response.incomplete;
  • only OpenCode Go Responses opts into the exact response.incomplete + status=incomplete + reason=max_output_tokens terminal;
  • malformed status/reason values, content_filter, duplicate terminals, and any parsed event after a terminal remain errors;
  • buffered and live OpenCode paths now preserve stop_reason=max_tokens, usage, and message_stop for that exact case.

Verification after the rebase:

  • cargo test --offline --all-targets --quiet: 987 passed, 0 failed;
  • cargo +1.96.0 clippy --offline --all-targets -- -D warnings;
  • cargo +1.96.0 fmt --all -- --check;
  • cargo build --release --locked --offline;
  • live gpt-5.4 Read/Edit/Bash/image/classifier canary passed through an isolated proxy;
  • live OpenCode Luna truncation produced upstream response.incomplete/max_output_tokens and downstream stop_reason=max_tokens + message_stop, with no event:error;
  • live opencode-go/qwen3.8-max completed Read/Edit/Bash, classifier routing, thinking, and PNG input through the rebased branch.

Keeping the PR in Draft for now, as requested, while the expanded compatibility results are reviewed.

@mulfyx
mulfyx marked this pull request as ready for review August 8, 2026 21:56
@mulfyx

mulfyx commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: the expanded compatibility review is complete, so the previous Draft note is no longer current.

  • all 31 catalog model IDs were covered; 27 available routes were exercised and four were confirmed unavailable at the account or endpoint boundary;
  • the available routes were checked through Claude Code for core tool use, planning, nested agents, Workflow, and manual /compact followed by a fresh resume;
  • the OpenCode Go Responses regression found during review is fixed, including buffered/live max_output_tokens handling and post-terminal event fencing;
  • no additional transport, decode, or stream-state regression attributable to this PR was found;
  • the remaining failures were model-specific behavior rather than proxy translation failures: Kimi K2.6 returned an empty compact summary, Kimi K2.5 exceeded Claude Code's 2048-token compact-summary limit, Kimi K2.7 did not complete the Workflow contract, and Qwen 3.8 Max did not complete the nested-agent contract.

The focused regression suite, full Rust test suite, clippy, formatting, release build, and CI checks are green. The PR is now marked ready for review.

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