fix(ai): recognized proxy stale-anchor codes as codex previous_response chain expiry

`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
This commit is contained in:
roboomp
2026-07-05 18:14:32 +00:00
parent d806ac5e4c
commit fc71df400f
3 changed files with 127 additions and 4 deletions
+4
View File
@@ -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
@@ -1171,12 +1171,23 @@ function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start"
return "toolcall_start";
}
const CODEX_STALE_PREVIOUS_RESPONSE_CODES: Record<string, true> = {
// 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 (
@@ -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<Record<string, unknown>> = [];
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<string, unknown>;
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<string, ProviderSessionState>();
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-");