From fc71df400fd6c2d020c2f6e2c8e6355543577f0e Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 5 Jul 2026 18:14:32 +0000 Subject: [PATCH] fix(ai): recognized proxy stale-anchor codes as codex previous_response chain expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isCodexStalePreviousResponseError` short-circuited for `CodexProviderStreamError` by comparing `error.code` only to `previous_response_not_found`, so a proxy code such as `codex_previous_response_stale` never reached the message-based fallback. WebSocket continuations that hit a stale upstream response anchor surfaced the terminal error to the user instead of retrying with full context. Codex WebSocket continuations now treat both the OpenAI-standard `previous_response_not_found` and the proxy `codex_previous_response_stale` code as the same recovery class, and every `Error` — not just plain ones — falls through to the existing `previous[ _]?response` / `expired|stale|...` message check. Fixes #4624 --- packages/ai/CHANGELOG.md | 4 + .../src/providers/openai-codex-responses.ts | 19 ++- packages/ai/test/openai-codex-stream.test.ts | 108 ++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d49636d82..ba1b5ca46 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI Codex WebSocket continuations to treat proxy stale-anchor codes such as `codex_previous_response_stale` as an expired `previous_response_id` chain — same recovery class as the OpenAI-standard `previous_response_not_found` — so the turn is retried with full context instead of surfacing the error to the user ([#4624](https://github.com/can1357/oh-my-pi/issues/4624)). + ## [16.3.7] - 2026-07-05 ### Fixed diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 08ca0a434..abbbf6d53 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -1171,12 +1171,23 @@ function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start" return "toolcall_start"; } +const CODEX_STALE_PREVIOUS_RESPONSE_CODES: Record = { + // OpenAI-standard code for an expired/missing `previous_response_id` chain. + previous_response_not_found: true, + // Proxy-specific: upstream response anchor expired. Same recovery class — + // retry the turn with full context and no `previous_response_id`. + codex_previous_response_stale: true, +}; + function isCodexStalePreviousResponseError(error: unknown): boolean { - if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found"; if (!(error instanceof Error)) return false; - if ((error as { code?: string }).code === "previous_response_not_found") return true; - // "unsupported": the backend intermittently rejects the parameter outright - // with `{"detail":"Unsupported parameter: previous_response_id"}` (no + if ("code" in error && typeof error.code === "string" && CODEX_STALE_PREVIOUS_RESPONSE_CODES[error.code]) { + return true; + } + // Message-based fallback for providers/proxies that report the condition + // without a canonical code. Also covers "unsupported": the backend + // intermittently rejects the parameter outright with + // `{"detail":"Unsupported parameter: previous_response_id"}` (no // `error.code`); treat it like a stale chain so the turn replays with full // context instead of surfacing the 400. return ( diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index b7979ab40..439846d1f 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -2660,6 +2660,114 @@ describe("openai-codex streaming", () => { lastPreviousResponseId: undefined, }); }); + it("retries websocket continuations when a proxy reports a stale previous response anchor", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + const token = createCodexTestToken(); + const sentRequests: Array> = []; + const fetchMock = vi.fn(async () => { + throw new Error("SSE fallback should not be called"); + }); + + class ProxyStaleAnchorWebSocket extends MockWebSocket { + constructor(url: string, options?: { headers?: WsHeaders }) { + super(url, options); + this.scheduleOpen(); + } + + send(data: string): void { + const request = JSON.parse(data) as Record; + sentRequests.push(request); + const requestIndex = sentRequests.length; + + if (requestIndex === 1) { + this.emitCodexResponse({ + messageId: "msg_1", + responseId: "resp_1", + text: "First answer", + terminalType: "response.completed", + includeCreated: true, + }); + return; + } + + if (requestIndex === 2) { + expect(request.previous_response_id).toBe("resp_1"); + this.sendJson({ + type: "error", + code: "codex_previous_response_stale", + message: "Upstream previous response anchor expired; retry without previous_response_id.", + }); + return; + } + + if (requestIndex === 3) { + expect(request.previous_response_id).toBeUndefined(); + this.emitCodexResponse({ + messageId: "msg_3", + responseId: "resp_3", + text: "Second answer", + terminalType: "response.completed", + includeCreated: true, + }); + return; + } + + throw new Error(`Unexpected websocket request index: ${requestIndex}`); + } + } + + global.WebSocket = ProxyStaleAnchorWebSocket as unknown as typeof WebSocket; + const model = createCodexTestModel("https://chatgpt.com/backend-api"); + const providerSessionState = new Map(); + const firstContext: Context = { + systemPrompt: ["You are a helpful assistant."], + messages: [{ role: "user", content: "First question", timestamp: Date.now() }], + }; + const firstResponse = await streamOpenAICodexResponses(model, firstContext, { + fetch: fetchMock as FetchImpl, + apiKey: token, + sessionId: "ws-proxy-stale-anchor-session", + providerSessionState, + }).result(); + const secondContext: Context = { + systemPrompt: ["You are a helpful assistant."], + messages: [ + ...firstContext.messages, + firstResponse, + { role: "user", content: "Second question", timestamp: Date.now() + 1 }, + ], + }; + + const secondResponse = await streamOpenAICodexResponses(model, secondContext, { + fetch: fetchMock as FetchImpl, + apiKey: token, + sessionId: "ws-proxy-stale-anchor-session", + providerSessionState, + }).result(); + + expect(secondResponse.stopReason).toBe("stop"); + expect(JSON.stringify(secondResponse.content)).toContain("Second answer"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(sentRequests).toHaveLength(3); + expect(sentRequests[2]?.prompt_cache_key).toBe("ws-proxy-stale-anchor-session"); + const retryInput = sentRequests[2]?.input; + expect(Array.isArray(retryInput)).toBe(true); + expect(JSON.stringify(retryInput)).toContain("First question"); + expect(JSON.stringify(retryInput)).toContain("Second question"); + + const stats = getOpenAICodexWebSocketDebugStats(model, { + sessionId: "ws-proxy-stale-anchor-session", + providerSessionState, + }); + expect(stats).toEqual({ + fullContextRequests: 2, + deltaRequests: 1, + lastInputItems: (retryInput as unknown[]).length, + lastDeltaInputItems: undefined, + lastPreviousResponseId: undefined, + }); + }); it("uses websocket v2 beta header when v2 mode is enabled", async () => { const tempDir = TempDir.createSync("@pi-codex-stream-");