Skip to content

fix(responses): reject expired forward continuations - #749

Merged
lidge-jun merged 1 commit into
lidge-jun:devfrom
LeoWang331:test/issue-702-expired-replay-state
Jul 30, 2026
Merged

fix(responses): reject expired forward continuations#749
lidge-jun merged 1 commit into
lidge-jun:devfrom
LeoWang331:test/issue-702-expired-replay-state

Conversation

@LeoWang331

@LeoWang331 LeoWang331 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • fail closed when a canonical OpenAI forward continuation carries previous_response_id but its local replay state is unavailable or expired
  • return the existing structured 400 invalid_request_error before authentication, adapter construction, or model-upstream dispatch; canonical replay misses also avoid unnecessary quota priming
  • preserve fresh replay expansion, ordinary requests, and native previous_response_id support for API-key Responses providers
  • harden the Issue Codex App resume silently drops prior context after the 1-hour replay-state TTL expires #702 integration harness so partially initialized servers, fetch, and Date.now are always restored

Fixes #702

Root cause

The canonical ChatGPT Responses backend rejects native previous_response_id. OpenCodex therefore depends on its local replay state to expand the omitted history before forwarding. When that state expired, the prior path logged the replay miss, stripped previous_response_id, and still forwarded only the continuation delta, silently losing conversation context.

Fail-closed conditions

The request is rejected only when all of the following are true:

  1. the original request carries previous_response_id;
  2. local replay input was not successfully expanded;
  3. the final route is the canonical OpenAI forward provider; and
  4. that upstream cannot safely recover the complete history.

The rejection happens before any upstream request. Expired continuations return HTTP 400 with invalid_request_error.

Compatibility controls

  • Expired forward continuation: rejected locally; no upstream request is made.
  • Fresh forward continuation: replay history is expanded and the upstream request succeeds.
  • Ordinary request without previous_response_id: succeeds normally.
  • API-key Responses provider: native previous_response_id is preserved and succeeds normally.

Validation

The local full-suite single-command run did not complete within the available execution time, so this PR does not claim that the full suite passed locally. Repository CI is the complete cross-platform validation authority for Linux, Windows, and macOS.

Summary by CodeRabbit

  • Bug Fixes
    • Continuation requests that reuse an outdated or improperly referenced previous response now fail clearly with an invalid_request_error, instructing users to start a new session.
    • Avoided unnecessary quota priming for these invalid replay attempts.
    • Improved forwarding behavior to preserve native previous-response continuation for API-key responses, and correctly forward expanded context for valid fresh continuations.
  • Tests
    • Added end-to-end coverage for expired, fresh, and ordinary forward continuation scenarios.

@github-actions github-actions Bot added the bug Something isn't working label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Canonical OpenAI forward requests now reject unavailable local replay state instead of forwarding incomplete context. Quota priming is skipped for these misses, while fresh replay, ordinary requests, and API-key provider behavior are covered by end-to-end tests.

Changes

Expired forward replay handling

Layer / File(s) Summary
Replay detection and quota gating
src/server/responses/core.ts
Detects unexpanded previousResponseId values and conditionally skips subagent quota priming for canonical OpenAI forward requests.
Fail-closed forward requests
src/server/responses/core.ts
Returns a 400 invalid_request_error before authentication and adapter setup when canonical forward replay state cannot be expanded.
Replay regression scenarios
tests/issue-702-expired-replay-state.test.ts
Adds isolated coverage for expired replay rejection, fresh replay expansion, ordinary requests, quota-prime suppression, and native API-key previous_response_id forwarding.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

Suggested reviewers: wibias, lidge-jun, ingwannu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code now fails closed for canonical OpenAI forward replay misses with a 400 error and adds TTL regression coverage, satisfying #702.
Out of Scope Changes check ✅ Passed The new test harness and core response changes are directly tied to the expired-continuation fix and do not introduce unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: rejecting expired forward continuations in Responses handling.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8db38a2281

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1161 to +1165
if (
isThreadSpawnRequest(req.headers)
&& !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
) {
await maybePrimeSubagentQuota(config);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prime quota before deciding the continuation route

When a thread_spawn continuation was previously served by an API-key fallback, then local replay and quota caches become stale (for example after a proxy restart), this condition suppresses maybePrimeSubagentQuota. Unknown quota is treated as available by fallback selection, so the request remains on the canonical route and is rejected below, even though priming would identify the exhausted primary and return the continuation to the API-key provider that owns its upstream response ID. Preserve quota-aware fallback selection, or otherwise evaluate continuation-capable fallbacks, before rejecting.

Useful? React with 👍 / 👎.

Comment on lines +1219 to +1223
if (
hasUnexpandedPreviousResponse
&& isCanonicalOpenAiForwardProvider(route.provider)
) {
return formatErrorResponse(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Continue combo failover to the provider that owns the response

When a combo continuation's local replay has expired and the current target is canonical OpenAI, this local 400 invalid_request_error is classified as stop by comboFailureDecision, so no later target is attempted. This breaks a round-robin/failover chain when the response ID was created by a later API-key Responses target, which could natively resume it. Skip incapable canonical targets for replay misses, or make this target-specific failure hop so the owning provider can be tried.

Useful? React with 👍 / 👎.

Comment on lines +1220 to +1222
hasUnexpandedPreviousResponse
&& isCanonicalOpenAiForwardProvider(route.provider)
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject misses for every forward Responses provider

When a custom or noncanonical openai-responses provider uses authMode: "forward", this canonical-URL check is false, so an expired continuation still proceeds. The adapter strips previous_response_id for every forward provider, not only the canonical URL, and consequently sends only the current delta upstream—the same silent context loss this change is intended to prevent. Align this fail-closed predicate with the adapter's forward-mode stripping predicate, or preserve native continuation IDs for forward providers that actually support them.

Useful? React with 👍 / 👎.

return formatErrorResponse(
400,
"invalid_request_error",
"OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Document the new continuation-expiry failure

This adds a user-visible terminal condition: after replay TTL expiry, eviction, or loss of the local snapshot, canonical continuations now return 400 and require a new session, but docs-site/src/content/docs/reference/architecture.md only says remembered previous_response_id input is expanded when available and no guide describes this failure or recovery. Update the English documentation and keep its translated counterparts consistent so users can diagnose the new response.

AGENTS.md reference: AGENTS.md:L152-L153

Useful? React with 👍 / 👎.

@LeoWang331
LeoWang331 force-pushed the test/issue-702-expired-replay-state branch from 8db38a2 to 6521518 Compare July 30, 2026 11:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/responses/core.ts`:
- Around line 1157-1166: The prime decision currently occurs before fallback
resolution, allowing subagent selection to use stale quota data when the
fallback rewrites route. Move or defer maybePrimeSubagentQuota() until after the
fallback logic, and key the skip condition against the final route so canonical
replay misses still avoid priming while fallback selection can use refreshed
quota scores.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dea9634e-ef48-4c69-ac1d-59b6ec066645

📥 Commits

Reviewing files that changed from the base of the PR and between d0141e7 and 8db38a2.

📒 Files selected for processing (2)
  • src/server/responses/core.ts
  • tests/issue-702-expired-replay-state.test.ts

Comment thread src/server/responses/core.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/server/responses/core.ts (1)

1157-1167: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Quota-prime skip is still keyed off the pre-fallback route.

hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider) is evaluated here using the route from routeModel(config, parsed.modelId) at Line 1149 — before the thread-spawn fallback block (Lines 1177-1208) can rewrite route via applySubagentModelFallback/routeModel(config, fallback.to). This is the exact ordering a previous review flagged: applySubagentModelFallback selects its target model from cached quota scores, and maybePrimeSubagentQuotaprimeCodexPoolQuotas is what refreshes those scores. If a replay-miss on the canonical forward provider causes priming to be skipped here, but the fallback logic then rewrites route to a non-canonical provider (so the Line 1216-1229 fail-closed check no longer fires and the request proceeds), that fallback decision is made on stale/un-refreshed quota data.

Key the skip decision off the FINAL route (after fallback resolves), or defer maybePrimeSubagentQuota until fallback has settled, so only requests that actually end up fail-closing skip the prime call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/responses/core.ts` around lines 1157 - 1167, Move the quota-prime
skip decision in the thread-spawn flow until after the fallback block has
finalized route, or otherwise recompute it from the final route returned by
applySubagentModelFallback/routeModel. Ensure maybePrimeSubagentQuota is skipped
only when hasUnexpandedPreviousResponse remains true and the final route
provider is canonical OpenAI; fallback requests proceeding with a non-canonical
provider must still prime quota data.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/server/responses/core.ts`:
- Around line 1157-1167: Move the quota-prime skip decision in the thread-spawn
flow until after the fallback block has finalized route, or otherwise recompute
it from the final route returned by applySubagentModelFallback/routeModel.
Ensure maybePrimeSubagentQuota is skipped only when
hasUnexpandedPreviousResponse remains true and the final route provider is
canonical OpenAI; fallback requests proceeding with a non-canonical provider
must still prime quota data.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a97496f1-b90b-4b2c-8777-190941e46fa2

📥 Commits

Reviewing files that changed from the base of the PR and between 8db38a2 and 6521518.

📒 Files selected for processing (2)
  • src/server/responses/core.ts
  • tests/issue-702-expired-replay-state.test.ts

@lidge-jun
lidge-jun merged commit 6be54f5 into lidge-jun:dev Jul 30, 2026
5 of 11 checks passed
@lidge-jun

Copy link
Copy Markdown
Owner

Merged into dev as 6be54f588 (head 652151880).

Verified locally on the merged tree before pushing, not from the PR description:

  • The guard fires only when all three hold: previous_response_id is present, _previousResponseInputExpanded is not true, and isCanonicalOpenAiForwardProvider(route.provider) — which src/providers/openai-tiers.ts:32-35 defines as adapter: openai-responses and authMode: forward and the canonical Codex base URL. API-key Responses providers therefore keep native previous_response_id, which tests/issue-702-expired-replay-state.test.ts asserts directly.
  • Moving maybePrimeSubagentQuota behind the route decision is a real improvement: a request already destined for a local 400 no longer probes quota upstream first.
  • Falsification: 4 pass / 0 fail on the merged tree. Reverting only src/server/responses/core.ts to the pre-merge cf30878fc gives 3 pass / 1 fail; restoring it returns 4 pass / 0 fail. The test genuinely binds the fix rather than passing incidentally.
  • Full suite on the merged tree: 6080 pass / 0 fail, 6081 tests across 438 files.

One thing worth naming for anyone reading this later: the scope is wider than the issue title. Every replay miss now returns the same 400, not just TTL expiry — restart and capacity eviction included. That is the right call, and the message ("unavailable or expired") already says so, but it is a behaviour change beyond the reported symptom.

Thanks for the harness hardening too; restoring fetch and Date.now on partially initialized servers is the kind of thing that saves someone an afternoon later.

Closing as merged. Issue #702 is closed with the same evidence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants