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 명시) 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..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( @@ -3595,14 +3600,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"); 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({