Skip to content

fix(codex): retry terminal-only live completions - #70

Merged
raine merged 4 commits into
raine:mainfrom
Aotricx:agent/retry-empty-codex-completion
Jul 28, 2026
Merged

fix(codex): retry terminal-only live completions#70
raine merged 4 commits into
raine:mainfrom
Aotricx:agent/retry-empty-codex-completion

Conversation

@Aotricx

@Aotricx Aotricx commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • buffer initial Anthropic SSE framing until Codex produces semantic output
  • treat terminal-only response.completed / response.done events as retryable empty completions
  • reuse bounded retry logic, dropping previous_response_id so retries send full context
  • return explicit 503 after retry exhaustion instead of successful empty end_turn
  • preserve text, thinking, tool, web-search, and signature-only reasoning streams
  • prove the retry loop stays bounded: a smoke test with an always-empty upstream asserts exactly 1 + 10 attempts, then the explicit 503 (instant via a zero-delay retry test hook, same *_for_tests pattern as the websocket pool/continuation helpers)

Scope: this covers the WebSocket live-stream path (CCP_CODEX_TRANSPORT=websocket, the default). The buffered HTTP transport translates a terminal-only completion through the reducer and would still return an empty 200; left untouched here since the failure was only ever observed on the live path.

Failure mode

A live Codex request could end with only response.completed. The proxy translated that into HTTP 200 SSE containing message_start with content: [], followed by end_turn. Claude Code then exited mid-turn; its resume repair inserted the synthetic No response requested. message, making the failure look like model nonsense.

The new smoke fixture reproduces that exact empty response before the fix and proves a full-context retry afterward.

TDD evidence

RED:

second response body: event: message_start
... "content":[] ...
... "stop_reason":"end_turn" ...
test result: FAILED. 0 passed; 1 failed

GREEN:

smoke_codex_websocket_stream_retries_terminal_only_completion_with_full_context ... ok

Assertions cover retry text, three upstream attempts, prior-response use on the failed attempt, and full-context retry without previous_response_id.

Verification

  • cargo fmt --all --check
  • cargo test --all — 600 tests passed across unit and integration suites (includes the new exhaustion smoke test, smoke_codex_websocket_empty_completions_exhaust_to_service_unavailable, 0.03s)
  • cargo build --release --locked
  • focused Codex regression group — 195 passed
  • websocket stream regression group — 5 passed
  • CodeRabbit — 0 findings after one TDD-fixed signature-only edge case
  • isolated release binary — health passed; combined locally with fix(codex): honor credits after included limit #68, real Codex request streamed patched-proxy-ok and normal end_turn

cargo clippy --all-targets -- -D warnings remains blocked by 33 pre-existing stable-1.97 warnings outside changed files.

Independent from #68; no merge or deployment performed.

@HM-Silvey HM-Silvey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(withdrawn — posted from the wrong account; see review notes in the PR comments)

@Aotricx
Aotricx marked this pull request as ready for review July 20, 2026 15:12
@Aotricx

Aotricx commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed the full diff (all three commits) plus an independent second-model pass. Approving.

Correctness — verified in code:

  • Retry loop is sound: drop_live_continuation_for_retry clears previous_response_id/input_delta before the bounded backoff path (MAX_RETRYABLE_LIVE_STREAM_RETRIES = 10), so retries always resend full context and the loop cannot spin unbounded.
  • The discarded empty attempt never calls update_continuation_from_upstream, so its response id can't poison the next turn — locked by the smoke assertions on per-attempt previous_response_id.
  • pending_chunk is consumed on every return path (no double-flush possible); first semantic event always emits bytes, so the empty-pending-at-terminal branch is unreachable once output starts.
  • Semantic-flag placement covers all five output kinds (text, thinking, tool call, web-search completion, signature-only reasoning), each unit-tested — including the deliberate web-search added-vs-done asymmetry.
  • response.incomplete deliberately stays non-retryable (a max-tokens truncation would not improve on resend). Sensible.
  • Progress-reporting delay from buffering is metrics-only (monitor.stream_progress) — no watchdog consumes it, so nothing can time out on buffered silence.

Test evidence (ran locally on this branch):

  • cargo fmt --all -- --check clean
  • Full cargo test: 600 passed, 0 failed
  • Exhaustion smoke test asserts exactly 1 + 10 upstream attempts then the explicit 503 — the retry bound is proven end-to-end, and the zero-delay hook follows the repo's existing *_for_tests pattern

Noted, non-blocking:

  • Scope is the WebSocket live path (the default transport). The buffered path has the same theoretical failure shape; addressed separately in fix(codex): retry empty buffered completions #71.
  • The predicate ignores rate_limits.allowed; a credited snapshot followed by an upstream abort surfaces the existing missing-terminal error instead of a 429 — correct trade-off, documented in the PR body.

@raine
raine force-pushed the agent/retry-empty-codex-completion branch from a1b60a0 to 7d0fd4a Compare July 28, 2026 14:15
@raine
raine force-pushed the agent/retry-empty-codex-completion branch from 7d0fd4a to 062b305 Compare July 28, 2026 14:19
HM-Silvey and others added 2 commits July 28, 2026 16:20
Add a smoke test where the upstream answers every attempt with a
terminal-only completion: the proxy must stop after the initial attempt
plus MAX_RETRYABLE_LIVE_STREAM_RETRIES full-context retries and surface
the explicit 503 instead of an empty end_turn. A zero-delay test hook in
retry::sleep (same pattern as the existing *_for_tests helpers) keeps
the exhaustion run instant; a Drop guard restores real backoff even on
panic. Also document the pending_chunk single-flush invariant.
Live stream translation writes traffic artifacts while initial Anthropic
framing is still buffered. Empty completion retries discard those bytes, but
the capture previously presented them as events delivered to the client.

Translate the buffered prefix without a live capture sink, then parse and
record its Anthropic SSE events only when the prefix is committed downstream.
Later stream events continue to use direct capture. Extend the retry smoke test
to prove discarded end-turn framing stays out of downstream artifacts.

Client-visible streaming and retry behavior is unchanged.
@raine
raine force-pushed the agent/retry-empty-codex-completion branch from 062b305 to 23d22ea Compare July 28, 2026 14:25
@raine
raine merged commit 31fefce into raine:main Jul 28, 2026
1 check passed
@raine

raine commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Thanks

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.

3 participants