From 7273a0d1f51a6422c5396f3272375fba6ef55543 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 02:49:38 +0900 Subject: [PATCH 1/3] docs(devlog): plan the B-track continuation/quota stack delivery Records the dependency-ordered branch chain for #3889 and #3934, the CI trigger contract that keeps the lower layer PR-free, and the original-author preservation requirement. --- .../000_plan.md | 69 +++++++++++++++++++ .../010_phase1_l1_continuation_recovery.md | 56 +++++++++++++++ ...020_phase2_l2_ws_quota_generation_fence.md | 54 +++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 devlog/_plan/260908_b_track_quota_recovery_stack/000_plan.md create mode 100644 devlog/_plan/260908_b_track_quota_recovery_stack/010_phase1_l1_continuation_recovery.md create mode 100644 devlog/_plan/260908_b_track_quota_recovery_stack/020_phase2_l2_ws_quota_generation_fence.md diff --git a/devlog/_plan/260908_b_track_quota_recovery_stack/000_plan.md b/devlog/_plan/260908_b_track_quota_recovery_stack/000_plan.md new file mode 100644 index 0000000000..d73b85568a --- /dev/null +++ b/devlog/_plan/260908_b_track_quota_recovery_stack/000_plan.md @@ -0,0 +1,69 @@ +# 000_plan.md — B트랙 대화 복구·quota 스택 배송 + +## 목표 +#3889(만료된 forward continuation의 WebSocket 복구)과 #3934(자격증명 세대 기반 늦은 WS quota 차단)를 +원저자 기여를 보존한 수동 종속 브랜치 체인으로 재구성하고, 최종 tip 한 곳에서만 CI를 태워 +green이면 tip을 dev에 통합한다. + +## 제약 (사용자 지시) +- 로컬 스위트 절대 실행 금지: bun run test / test:changed / typecheck / build / install 모두 NOT RUN. +- 푸시는 `--no-verify`. +- CI는 최종 tip에만 트리거한다. 하위 레이어에는 PR을 열지 않는다. +- 원작 PR이 있으면 원저자를 Co-authored-by로 보존한다. +- tip이 dev에 머지되는 순간 연결 이슈도 닫는다. + +## CI 트리거 계약 (근거) +`.github/workflows/ci.yml`의 `on.pull_request`에는 base 브랜치 필터가 없다(주석에 stacked child PR을 +일부러 포함시켰다고 명시). 따라서 **PR을 여는 것 자체가 CI run을 만든다.** +`push:`는 `branches: [main, preview, dev]`로 제한되므로 포크/작업 브랜치 푸시는 CI를 만들지 않는다. +결론: 하위 레이어 L1은 **브랜치 푸시만** 하고 PR을 열지 않는다. tip L2에만 PR을 연다. + +## 의존성 정렬 (PHASE-SPLIT-01) +효율이 아니라 의존 구조로 나눈다. 두 변경 모두 `src/server/responses/core.ts`를 만지므로 +같은 파일 위에서 순서를 가진 체인으로 쌓는다. + +- L1 = #3889 continuation 복구 (core.ts:3598 부근 오류 코드 계약) +- L2 = #3934 WS quota 세대 펜싱 (core.ts:1004 부근 observer) — L1 위에 쌓는다 + +텍스트 충돌은 없다(두 훅 사이 거리 약 2600줄). 체인 순서는 리뷰 단위 분리를 위한 것이다. + +## 파일 변경 맵 +### L1 (#3889, 원저자 ykvv / y2ambition-ai) +- MODIFY `src/server/responses/core.ts` — 400 응답 코드를 `invalid_request_error` → `previous_response_not_found`, + 메시지를 "전체 대화를 다시 보내라"로 변경. HTTP 상태와 인증 전 거부 위치는 유지. +- MODIFY `tests/codex-integration/issue-702-expired-replay-state.test.ts` — 기존 HTTP 기대값의 code 갱신 + + expired/missing 두 모드의 WebSocket 재연결·전체 도구 이력 재전송 회귀 추가. +- MODIFY `docs-site/src/content/docs/guides/codex-integration.md`, `.../ko/guides/codex-integration.md` + +### L2 (#3934, 원저자 luvs01) +- MODIFY `src/server/responses/core.ts` — `codexWsQuotaObserver`에서 pool 자격증명 generation을 포착하고 + `isCodexAccountGenerationLive`가 false면 늦게 도착한 quota 프레임을 무시. +- MODIFY `tests/responses/responses-account-label.test.ts` — 교체된 자격증명의 늦은 quota가 지워진 상태를 + 되살리지 못하는 회귀 추가. + +## 범위 밖 (OUT) +- `REPLAY_TTL_MS` 등 캐시 보존 기간 변경 +- 인증/자격증명 회전 정책 변경 +- main-pool writer 소유권 규칙 변경 +- B트랙 외 항목(#3906/#3886/#3922/#3917/#3900/#3896/#3924/#3930/#3890) + +## 검증자 (PLAN-VERIFIER-REAL-01) +로컬 스위트가 금지되었으므로 **로컬 검증자는 NOT RUN으로 기록한다**. 유일한 실행 검증자는 +tip PR head SHA에 대한 hosted Cross-platform CI다. 관측 대상: 4 Linux shard, Windows, +macOS lane, gates(typecheck/lint/privacy scan), packaging. +- `gh api repos/lidge-jun/opencodex/actions/runs?head_sha=` → conclusion=success +- 이 CI는 `src/**`와 `tests/**`를 changes 필터에 포함하므로 실제로 이번 변경 대상을 관측한다. + +## 수용 기준 +1. L1/L2 커밋 각각에 원저자 Co-authored-by 트레일러가 살아 있다. +2. L1에는 PR이 없고 CI run도 없다. CI run은 tip 하나뿐이다. +3. tip head SHA의 CI conclusion이 success다. +4. 로컬 스위트 미실행, 푸시는 --no-verify. +5. tip이 dev 조상이 되고, #3889/#3934가 정리되며 연결 이슈가 닫힌다. + +## 우회 경로 (PLAN-BYPASS-NAMED-01) +- tier: E2 (hosted CI 게이트) +- 실행 주체: GitHub Actions + maintainer 통합 +- 알려진 우회: admin 권한 보유자는 CI 미완료 상태에서도 머지 가능. 이 계획은 그러지 않는다. +- 잔여 위험: 하위 레이어 L1은 자체 CI 없이 tip 누적 CI로만 증명된다. 사용자 지시에 따른 의도된 선택. +- 문구 하향: 없음. diff --git a/devlog/_plan/260908_b_track_quota_recovery_stack/010_phase1_l1_continuation_recovery.md b/devlog/_plan/260908_b_track_quota_recovery_stack/010_phase1_l1_continuation_recovery.md new file mode 100644 index 0000000000..4c054dd8c3 --- /dev/null +++ b/devlog/_plan/260908_b_track_quota_recovery_stack/010_phase1_l1_continuation_recovery.md @@ -0,0 +1,56 @@ +# 010_phase1_l1_continuation_recovery.md — L1 (#3889) 브랜치 구성 + +## 목적 +만료·부재한 forward continuation 상태를 Codex WebSocket 클라이언트가 스스로 복구할 수 있게, +프록시가 돌려주는 400 오류의 코드를 클라이언트가 인식하는 `previous_response_not_found`로 바꾼다. + +## 브랜치 +`codex/b-stack-l1-continuation-recovery`, base = `origin/dev`. + +## 커밋 계약 +원저자 보존이 필수다. 체리픽으로 원 커밋의 author를 그대로 유지한다. + +``` +git cherry-pick -x e8d82a181ea0daa06c5111c09e0148475e45458f +``` + +체리픽은 원 커밋의 author(ykvv <229483879+y2ambition-ai@users.noreply.github.com>)를 보존한다. +squash 병합 시 author가 소실될 수 있으므로 커밋 메시지에 트레일러도 추가한다: + +``` +Co-authored-by: ykvv <229483879+y2ambition-ai@users.noreply.github.com> +``` + +## 정확한 변경 (before → after) +`src/server/responses/core.ts` 약 3598행: + +```diff + if ( + hasUnexpandedPreviousResponse + && isCanonicalOpenAiForwardProvider(route.provider) + ) { + 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.", ++ "previous_response_not_found", ++ "OpenAI forward continuation state is unavailable or expired; resend the full conversation without previous_response_id.", + ); + } +``` + +가드 위치(인증·어댑터·upstream I/O 이전)는 바뀌지 않는다. HTTP 상태 400도 유지한다. + +테스트: `tests/codex-integration/issue-702-expired-replay-state.test.ts` +- 기존 HTTP 케이스: `code`를 `previous_response_not_found`로 갱신, `type`은 `invalid_request_error` 유지. +- 신규: expired/missing 두 모드로 WebSocket 연결 → 거부 확인 → upstream 요청 0건 확인 → + 재연결 후 전체 이력 재전송 → upstream 1건 + `previous_response_id` 없음 + 도구 호출/결과 쌍 보존. + +문서: `docs-site/src/content/docs/guides/codex-integration.md` 및 한국어 페이지에 복구 경계 문단 추가. + +## 검증 +로컬 스위트 NOT RUN(사용자 금지). 이 레이어는 PR을 열지 않으므로 자체 CI도 없다. +증명은 L2 tip의 누적 CI가 담당한다. + +## 감사 반영 +서브에이전트 audit-3889의 결과에 따라 문서의 TTL 수치와 error type/code 매핑을 확정한다. diff --git a/devlog/_plan/260908_b_track_quota_recovery_stack/020_phase2_l2_ws_quota_generation_fence.md b/devlog/_plan/260908_b_track_quota_recovery_stack/020_phase2_l2_ws_quota_generation_fence.md new file mode 100644 index 0000000000..8ac37b83f9 --- /dev/null +++ b/devlog/_plan/260908_b_track_quota_recovery_stack/020_phase2_l2_ws_quota_generation_fence.md @@ -0,0 +1,54 @@ +# 020_phase2_l2_ws_quota_generation_fence.md — L2 (#3934) tip 레이어 + +## 목적 +pool 자격증명이 교체된 뒤 이전 WebSocket 연결에서 늦게 도착한 quota 프레임이, +새 자격증명을 위해 비워둔 quota 상태를 되살리지 못하게 막는다. + +## 브랜치 +`codex/b-stack-l2-ws-quota-generation`, base = `codex/b-stack-l1-continuation-recovery` (L1 위에 쌓음). +이 브랜치가 스택의 tip이며, **PR은 여기에만 연다.** + +## 커밋 계약 +``` +git cherry-pick -x e5c01f44e9736baba5b3a993c7f489f6b60d5ddd +``` +원저자 luvs01 보존 + `Co-authored-by: luvs01 ` 트레일러. + +## 정확한 변경 (before → after) +`src/server/responses/core.ts` 약 1004행: + +```diff ++import { isCodexAccountGenerationLive } from "../../codex/account-store"; + + function codexWsQuotaObserver(authCtx, provider): CodexWsQuotaObserver | undefined { + if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined; + const { accountId, writerGeneration } = authCtx; ++ const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined; + const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; +- return headers => applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); ++ return headers => { ++ if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return; ++ applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); ++ }; + } +``` + +`credentialGeneration === undefined`면 기존 동작을 그대로 유지한다(main-pool·비pool 경로 무변경). + +테스트: `tests/responses/responses-account-label.test.ts` +- quota 10 전달 → 자격증명 교체 → quota clear → 옛 연결에서 quota 100 전달 → 최종 상태가 null인지 확인. + +## L1과의 관계 +같은 파일이지만 서로 다른 함수(약 2600줄 간격)라 텍스트 충돌이 없다. +체인 순서는 리뷰 단위를 나누기 위한 것이며, L2 diff는 이 변경만 보여준다. + +## CI 계약 +`.github/workflows/ci.yml`의 `on.pull_request`는 base 필터가 없어 PR 생성 즉시 CI가 붙는다. +따라서 L1에는 PR을 열지 않고, tip인 L2에만 PR을 연다 → CI run 정확히 1개. +`changes` 필터가 `src/**`, `tests/**`, `docs-site` 외 경로를 보므로 이 변경 세트는 `ci=true`가 되어 +4개 Linux shard, Windows, macOS lane, gates가 모두 돈다. + +## 머지 후 처리 +- tip PR 머지 → `git merge-base --is-ancestor`로 dev 조상 확인 +- #3889, #3934: 내용이 dev에 들어갔으므로 원저자 크레딧을 명시하며 닫는다 +- 연결 이슈: dev 머지 시점에 닫는다 (PR base가 dev라 GitHub 자동 종료가 안 됨 — AGENTS.md 명시) From 53175334046cbc6924938a2a38d19f74f10a4668 Mon Sep 17 00:00:00 2001 From: ykvv <229483879+y2ambition-ai@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:11:42 +0800 Subject: [PATCH 2/3] fix(responses): recover expired forward continuation over WebSocket A Codex WebSocket task stopped with a non-retryable 400 when its canonical ChatGPT forward replay entry expired or was missing. The proxy correctly refuses to forward a context-free delta, but reported invalid_request_error, which Codex treats as terminal. Return the existing previous_response_not_found classification instead, so Codex reconnects and resends its full retained input, including completed tool calls and their results. Rejection still happens before provider credential resolution, adapter construction, and upstream I/O; the cache TTL and capacity bounds are unchanged. HTTP clients must explicitly replay their full context. (cherry picked from commit e8d82a181ea0daa06c5111c09e0148475e45458f) Co-authored-by: ykvv <229483879+y2ambition-ai@users.noreply.github.com> --- .../content/docs/guides/codex-integration.md | 9 ++ .../docs/ko/guides/codex-integration.md | 9 ++ src/server/responses/core.ts | 6 +- .../issue-702-expired-replay-state.test.ts | 119 +++++++++++++++++- 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index f5592c0a72..1bd355c34b 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -213,6 +213,15 @@ provider advertises `supports_websockets = true` only when `"websockets": true`; built-in provider may try WebSocket first, and a disabled proxy returns `426` so Codex falls back to HTTP/SSE. +If a canonical ChatGPT forward continuation references expired or missing local replay state, +opencodex returns `previous_response_not_found` before sending anything upstream. Codex's +WebSocket client recognizes this error and can reconnect with its full retained context, +including completed tool calls and their results, within its normal stream retry budget. An +idle task therefore does not need a new task solely because the proxy's one-hour cache expired. +The cache remains bounded; this does not extend retention or recover history the client no +longer has. HTTP clients must handle the error explicitly and resend their full context without +`previous_response_id`. Retrying only the same ID cannot recover missing state. + ### Authless Codex Desktop (opt-in) In **Dashboard → Overview**, **Open Codex without signing in** controls this existing diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 1f324adaf2..41f90537cd 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -119,6 +119,15 @@ Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고합니다. loopback에서는 Codex의 빌트인 provider가 먼저 WebSocket을 시도할 수 있으며, 비활성화된 proxy는 `426`을 반환해서 Codex가 HTTP/SSE로 fallback합니다. +네이티브 ChatGPT forward 요청의 로컬 재생 상태가 만료되었거나 없으면 opencodex는 +upstream 요청 전에 `previous_response_not_found`를 반환합니다. Codex WebSocket 클라이언트는 +일반 스트림 재시도 한도 안에서 다시 연결하고, 완료된 도구 호출과 결과를 포함한 현재 보유 +컨텍스트 전체를 다시 보낼 수 있습니다. 따라서 프록시의 1시간 캐시가 만료되었다는 이유만으로 +새 작업을 만들 필요는 없습니다. 캐시 한도와 보존 기간은 그대로이며, 클라이언트가 더 이상 +보유하지 않는 기록을 복구하는 기능은 아닙니다. HTTP 클라이언트는 이 오류를 직접 처리하고 +`previous_response_id` 없이 전체 컨텍스트를 다시 보내야 합니다. 같은 ID만 재시도해서는 +누락된 상태를 복구할 수 없습니다. + ## 스레드 식별자와 대화 기록 기본 loopback 형식은 새 thread에 네이티브 `openai` provider 태그를 유지하므로 일반적인 resume history는 다시 매핑할 필요가 없습니다. sync와 restore는 일치하는 백업 manifest만 적용하여 각 thread의 원래 provider, source, event marker를 정확히 복원합니다. manifest가 없는 `opencodex` row는 변경하지 않으며, legacy 재태깅을 명시적으로 강제하려는 경우에만 `ocx recover-history --legacy-openai --yes`를 사용합니다. 이 명령은 의도적으로 범위가 넓습니다. 사용자 메시지가 있고 현재 `opencodex`로 표시된 모든 thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 사용하세요. non-loopback 전용 provider 모드는 활성 상태일 때만 history를 `opencodex` provider 아래로 미러링하고, 종료할 때는 백업된 메타데이터를 복원합니다. history를 건드리지 않으려면 `syncResumeHistory: false`로 설정하세요. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7281bcd305..3fd0501fad 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3595,14 +3595,16 @@ async function handleResponsesInner( // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream // I/O instead of stripping the id and silently forwarding a context-free delta (#702). + // Codex recognizes previous_response_not_found on WebSocket errors and reconnects with its + // full input. A generic invalid_request_error instead terminates the task after cache expiry. if ( hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider) ) { 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.", + "previous_response_not_found", + "OpenAI forward continuation state is unavailable or expired; resend the full conversation without previous_response_id.", ); } diff --git a/tests/codex-integration/issue-702-expired-replay-state.test.ts b/tests/codex-integration/issue-702-expired-replay-state.test.ts index b73439ecc9..13b96be638 100644 --- a/tests/codex-integration/issue-702-expired-replay-state.test.ts +++ b/tests/codex-integration/issue-702-expired-replay-state.test.ts @@ -21,7 +21,7 @@ import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; -import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; @@ -104,6 +104,46 @@ function completedSse(responseId: string, text: string): string { ].join("\n"); } +async function openResponseSocket(url: URL, headers: Record): Promise { + const target = new URL("/v1/responses", url); + target.protocol = "ws:"; + const socket = new WebSocket(target, { headers } as unknown as string[]); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.close(); + reject(new Error("response socket did not open")); + }, INTERNAL_DEADLINE_MS); + socket.onopen = () => { clearTimeout(timer); resolve(); }; + socket.onerror = () => { clearTimeout(timer); reject(new Error("response socket failed to open")); }; + }); + return socket; +} + +async function sendSocketTurn(socket: WebSocket, body: Record): Promise> { + return new Promise((resolve, reject) => { + const finish = (error?: Error, frame?: Record) => { + clearTimeout(timer); + socket.onmessage = socket.onclose = socket.onerror = null; + if (error) reject(error); + else resolve(frame!); + }; + const timer = setTimeout(() => finish(new Error("response socket did not reach a terminal event")), INTERNAL_DEADLINE_MS); + socket.onclose = () => finish(new Error("response socket closed before its terminal event")); + socket.onerror = () => finish(new Error("response socket failed")); + socket.onmessage = event => { + try { + const frame = JSON.parse(String(event.data)); + if (["error", "response.completed", "response.failed", "response.incomplete"].includes(frame.type)) { + finish(undefined, frame); + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + socket.send(JSON.stringify({ type: "response.create", ...body })); + }); +} + async function waitForRecordedResponseState(): Promise { const deadline = performance.now() + 1_000; while (performance.now() < deadline) { @@ -364,11 +404,86 @@ describe("Issue #702 expired forward replay state", () => { error: { message: expect.stringMatching(/continuation state.*expired/i), type: "invalid_request_error", - code: "invalid_request_error", + code: "previous_response_not_found", }, }); }); + test.each(["expired", "missing"] as const)("%s forward state lets a WebSocket client reconnect and replay full tool history", async mode => { + const upstreamRequests: Record[] = []; + const realNow = Date.now; + let server: ReturnType | null = null; + let socket: WebSocket | null = null; + const toolCall = { + type: "function_call", id: "fc_issue_702", call_id: "call_issue_702", + name: "lookup", arguments: '{"key":"historical"}', status: "completed", + }; + const toolResult = { + type: "function_call_output", call_id: "call_issue_702", output: "historical tool result", + }; + const history = [inputMessage(HISTORICAL_USER_SENTINEL), toolCall]; + const delta = [toolResult, inputMessage(CURRENT_USER_SENTINEL)]; + try { + if (mode === "expired") { + Date.now = () => realNow() - EXPIRED_AGE_MS; + rememberResponseState( + { input: [history[0]], store: false }, + { id: FIRST_RESPONSE_ID, status: "completed", output: [toolCall] }, + undefined, + { force: true }, + ); + Date.now = realNow; + expect(responseStateMetrics().oldestAgeMs).toBeGreaterThan(REPLAY_TTL_MS); + } + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "chatgpt.com" && url.pathname === "/backend-api/codex/responses") { + upstreamRequests.push(JSON.parse(String(init?.body))); + return new Response(completedSse("resp_issue_702_recovered", "recovered with full history"), { + headers: { "content-type": "text/event-stream" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + saveConfig({ ...forwardConfig(), websockets: true }); + server = startServer(0); + const headers = { + authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" })}`, + "chatgpt-account-id": "acct-issue-702", + }; + socket = await openResponseSocket(server.url, headers); + const rejected = await sendSocketTurn(socket, { + model: "gpt-5.5", previous_response_id: FIRST_RESPONSE_ID, input: delta, store: false, + }); + expect(rejected).toMatchObject({ + type: "error", status: 400, + error: { type: "invalid_request_error", code: "previous_response_not_found" }, + }); + expect(upstreamRequests).toHaveLength(0); + + // Codex recognizes this code, discards its incremental socket state, and reconnects + // with its complete input. The rejected delta must never be forwarded on its own. + socket.close(); + socket = await openResponseSocket(server.url, headers); + const recovered = await sendSocketTurn(socket, { + model: "gpt-5.5", input: [...history, ...delta], store: false, + tools: [{ type: "function", name: "lookup", parameters: { type: "object" } }], + }); + expect(recovered).toMatchObject({ type: "response.completed", response: { id: "resp_issue_702_recovered" } }); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]!.previous_response_id).toBeUndefined(); + // The canonical forward adapter removes item ids, but must preserve the call/result + // identity and every input item exactly once when the client supplies full history. + const { id: _itemId, ...forwardedToolCall } = toolCall; + expect(upstreamRequests[0]!.input).toEqual([history[0], forwardedToolCall, ...delta]); + } finally { + Date.now = realNow; + globalThis.fetch = originalFetch; + socket?.close(); + await server?.stop(true); + } + }, SERVER_BUDGET_MS); + test("forward mode expands fresh replay state before continuing upstream", async () => { const scenario = await runForwardScenario("fresh"); From a06bfa2f2553d7a9820523970b5c3c9ddf6b1a64 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 7 Sep 2026 15:22:55 +0900 Subject: [PATCH 3/3] fix(codex): fence late WebSocket quota by credential generation A streaming Codex request could keep receiving WebSocket quota metadata after its pool credential was replaced under the same local account ID. A late frame then repopulated the quota cleared for that replacement, making the new credential look exhausted. Capture the selected pool credential generation when the response's quota observer is created and ignore later observations once that generation is no longer live. Existing configuration-generation fencing and the main-pool writer are unchanged: main-pool carries no credential generation and keeps its own mainQuotaWriter identity check. (cherry picked from commit e5c01f44e9736baba5b3a993c7f489f6b60d5ddd) Co-authored-by: luvs01 --- src/server/responses/core.ts | 7 ++- .../responses/responses-account-label.test.ts | 59 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3fd0501fad..133ed9cdbc 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -15,6 +15,7 @@ import { nativeContextLimits } from "../../codex/catalog"; import { describeUpstreamConnectFailure } from "./upstream-error"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota"; +import { isCodexAccountGenerationLive } from "../../codex/account-store"; import { isCodexWsQuotaObservedResponse } from "./ws-upstream"; import { multiAgentGuidanceEnabled, @@ -1004,8 +1005,12 @@ export function usesCodexForwardPoolAuth( function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig): CodexWsQuotaObserver | undefined { if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined; const { accountId, writerGeneration } = authCtx; + const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined; const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; - return headers => applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); + return headers => { + if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return; + applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); + }; } export function preAuthUpstreamHostCircuitKey( diff --git a/tests/responses/responses-account-label.test.ts b/tests/responses/responses-account-label.test.ts index b7be5c8d7f..e96c4556d9 100644 --- a/tests/responses/responses-account-label.test.ts +++ b/tests/responses/responses-account-label.test.ts @@ -190,6 +190,65 @@ describe("Responses account usage attribution", () => { } }); + test("late WS quota from a replaced pool credential cannot repopulate cleared state", async () => { + const originalWebSocket = globalThis.WebSocket; + let releaseFinalQuota!: () => void; + const finalQuotaAllowed = new Promise(resolve => { releaseFinalQuota = resolve; }); + try { + await withPoolHome(async () => { + savePoolCredential("pool-ws-replaced"); + class MetadataSocket { + listeners = new Map void>>(); + constructor() { queueMicrotask(() => this.emit("open", {})); } + addEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + removeEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.set(type, (this.listeners.get(type) ?? []).filter(value => value !== listener)); + } + emit(type: string, event: unknown) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + send() { + const payload = (value: unknown) => this.emit("message", { data: JSON.stringify(value) }); + queueMicrotask(() => { + payload({ type: "codex.rate_limits", rate_limits: { + primary: { used_percent: 10, window_minutes: 10080 }, + } }); + payload({ type: "response.created", response: { id: "quota-response" } }); + void finalQuotaAllowed.then(() => { + payload({ type: "codex.rate_limits", rate_limits: { + primary: { used_percent: 100, window_minutes: 10080 }, + } }); + payload({ type: "response.completed", response: { id: "quota-response", status: "completed", output: [] } }); + }); + }); + } + close() { this.emit("close", {}); } + } + globalThis.WebSocket = MetadataSocket as unknown as typeof WebSocket; + globalThis.fetch = (async () => { throw new Error("unexpected HTTP request"); }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true }), + }), poolConfig(["pool-ws-replaced"]), { model: "", provider: "" }, { + codexWsRuntimeIdentity: "1.4.0", + }); + expect(getAccountQuota("pool-ws-replaced")?.weeklyPercent).toBe(10); + + savePoolCredential("pool-ws-replaced"); + clearAccountQuota("pool-ws-replaced"); + releaseFinalQuota(); + await response.text(); + + expect(getAccountQuota("pool-ws-replaced")).toBeNull(); + }); + } finally { + releaseFinalQuota(); + globalThis.WebSocket = originalWebSocket; + } + }); + test("main-pool and legacy added accounts carry their effective labels", async () => { await withPoolHome(async home => { writeFileSync(join(home, "auth.json"), JSON.stringify({