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:
@@ -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-");
|
||||
|
||||
Reference in New Issue
Block a user