handle openai websocket previous_response_not_found error

fixes #6931
This commit is contained in:
David Brailovsky
2026-07-22 10:26:24 +00:00
parent a5afc3f171
commit c5dcb26000
2 changed files with 238 additions and 6 deletions
@@ -1802,6 +1802,224 @@ describe("openai-codex streaming", () => {
});
});
it.each(["websocket", "sse"] as const)(
"recovers a missing cached websocket continuation via %s",
async (recoveryTransport) => {
const token = mockToken();
const sessionId = `missing-continuation-${recoveryTransport}`;
const encoder = new TextEncoder();
const fetchMock = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const sentBodies: Array<{
connectionId: number;
input: unknown[];
previous_response_id?: string;
}> = [];
let connections = 0;
class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
readyState = MockWebSocket.OPEN;
private readonly connectionId = ++connections;
private listeners = new Map<string, Set<(event: unknown) => void>>();
constructor(_url: string, _protocols?: string | string[] | { headers?: Record<string, string> }) {
queueMicrotask(() => this.dispatch("open", {}));
}
addEventListener(type: string, listener: (event: unknown) => void): void {
let listeners = this.listeners.get(type);
if (!listeners) {
listeners = new Set();
this.listeners.set(type, listeners);
}
listeners.add(listener);
}
removeEventListener(type: string, listener: (event: unknown) => void): void {
this.listeners.get(type)?.delete(listener);
}
send(data: string): void {
const body = JSON.parse(data) as { input: unknown[]; previous_response_id?: string };
sentBodies.push({ ...body, connectionId: this.connectionId });
if (sentBodies.length === 2) {
this.dispatchEvents([
{
type: "codex.rate_limits",
plan_type: "plus",
rate_limits: {
allowed: true,
limit_reached: false,
primary: {
used_percent: 7,
window_minutes: 10080,
reset_after_seconds: 556112,
reset_at: 1785269351,
},
secondary: null,
},
code_review_rate_limits: null,
additional_rate_limits: null,
credits: { has_credits: false, unlimited: false, balance: "0" },
promo: null,
},
{
type: "error",
status: 400,
error: {
code: "previous_response_not_found",
message: "Previous response with id 'resp_1' not found.",
param: "previous_response_id",
},
},
]);
return;
}
if (sentBodies.length === 3 && recoveryTransport === "sse") {
queueMicrotask(() => this.dispatch("error", { message: "retry websocket failed" }));
return;
}
const response =
sentBodies.length === 1
? { responseId: "resp_1", messageId: "msg_1", text: "Hello" }
: { responseId: "resp_2", messageId: "msg_2", text: "Recovered" };
this.dispatchEvents([
{ type: "response.created", response: { id: response.responseId } },
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "message",
id: response.messageId,
role: "assistant",
status: "in_progress",
content: [],
},
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: response.messageId,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: response.text }],
},
},
{
type: "response.completed",
response: {
id: response.responseId,
status: "completed",
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
},
},
]);
}
close(): void {
this.readyState = MockWebSocket.CLOSED;
}
private dispatchEvents(events: unknown[]): void {
queueMicrotask(() => {
for (const event of events) {
this.dispatch("message", { data: JSON.stringify(event) });
}
});
}
private dispatch(type: string, event: unknown): void {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
vi.stubGlobal("WebSocket", MockWebSocket);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const firstContext: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
const first = await streamOpenAICodexResponses(model, firstContext, {
apiKey: token,
sessionId,
transport: "websocket-cached",
}).result();
const secondContext: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
};
const eventTypes: string[] = [];
const secondStream = streamOpenAICodexResponses(model, secondContext, {
apiKey: token,
sessionId,
transport: "websocket-cached",
});
for await (const event of secondStream) {
eventTypes.push(event.type);
}
const second = await secondStream.result();
expect(second.stopReason).toBe("stop");
expect(second.content.find((content) => content.type === "text")?.text).toBe(
recoveryTransport === "sse" ? "Hello" : "Recovered",
);
expect(eventTypes.filter((type) => type === "start")).toHaveLength(1);
expect(eventTypes).not.toContain("error");
expect(connections).toBe(2);
expect(sentBodies).toHaveLength(3);
expect(sentBodies.map((body) => body.connectionId)).toEqual([1, 1, 2]);
expect(sentBodies[1].previous_response_id).toBe("resp_1");
expect(sentBodies[1].input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Now finish" }] }]);
expect(sentBodies[2].previous_response_id).toBeUndefined();
expect(sentBodies[2].input).toHaveLength(3);
expect(sentBodies[2].input.at(-1)).toEqual({
role: "user",
content: [{ type: "input_text", text: "Now finish" }],
});
expect(fetchMock).toHaveBeenCalledTimes(recoveryTransport === "sse" ? 1 : 0);
expect(getOpenAICodexWebSocketDebugStats(sessionId)).toMatchObject({
requests: 3,
connectionsCreated: 2,
connectionsReused: 1,
fullContextRequests: 2,
deltaRequests: 1,
websocketFailures: recoveryTransport === "sse" ? 1 : 0,
sseFallbacks: recoveryTransport === "sse" ? 1 : 0,
});
},
);
it.each([
["retry-after-ms", () => ({ "content-type": "application/json", "retry-after-ms": "1500" }), 1500],
["retry-after seconds", () => ({ "content-type": "application/json", "retry-after": "60" }), 60_000],