diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1039979e57f..66e28cb9ab2 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -370,6 +370,12 @@ disk, and entry ceilings; this does not recover history the client no longer has must handle the error explicitly and resend their full context without `previous_response_id`. Retrying only the same ID cannot recover missing state. +The same refusal applies when the referenced state belongs to a different client task scope, +including when the new request appears to carry complete input. The proxy cannot prove that input +is complete, so it does not silently remove `previous_response_id` or reveal whether matching state +exists. Retry with the complete conversation and omit `previous_response_id`; matching scopes and +legacy continuations where both scopes are absent or blank continue to replay normally. + The same recovery signal applies to every routed destination, because only the native Responses passthrough can answer a turn whose history this proxy lost — it forwards `previous_response_id` to a backend that stored the chain. Every other wire rebuilds the conversation from each request's 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 f1de9ec20ac..fa172286f18 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -211,6 +211,12 @@ upstream 요청 전에 `previous_response_not_found`를 반환합니다. Codex W HTTP 클라이언트는 이 오류를 직접 처리하고 `previous_response_id` 없이 전체 컨텍스트를 다시 보내야 합니다. 같은 ID만 재시도해서는 누락된 상태를 복구할 수 없습니다. +참조한 상태의 client task scope가 현재 요청과 다를 때도 같은 거부 응답을 반환합니다. 새 요청이 +전체 입력처럼 보여도 프록시는 그것이 완전한 대화인지 증명할 수 없으므로 +`previous_response_id`를 조용히 제거하거나 일치하는 상태의 존재 여부를 노출하지 않습니다. +전체 대화를 담고 `previous_response_id`를 뺀 요청으로 명시적으로 다시 시도하세요. 정규화된 +scope가 같거나 양쪽 scope가 모두 없거나 비어 있는 기존 unscoped continuation은 계속 replay합니다. + routed 목적지에는 모두 같은 복구 신호가 적용됩니다. 프록시가 잃어버린 기록을 대신 볼 수 있는 것은 네이티브 Responses 패스스루뿐입니다. 체인을 저장해 둔 백엔드로 `previous_response_id`를 그대로 넘기기 때문입니다. 나머지 wire는 매 턴 요청에 담긴 입력만으로 대화를 다시 구성하므로, diff --git a/src/responses/state.ts b/src/responses/state.ts index 35237de6db7..1037f0d28f0 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -135,11 +135,10 @@ export type ResidentInput = Omit; export type PreviousResponseReplayFailure = { code: "previous_response_not_found"; - reason: "spill_missing" | "spill_corrupt" | "spill_failed" | "spill_too_large"; + reason: "spill_missing" | "spill_corrupt" | "spill_failed" | "spill_too_large" | "scope_mismatch"; }; const states = new Map(); -const replayScopeMismatches = new WeakSet(); let storedResponseBytes = 0; let residentResponseBytes = 0; let oldestResidentId: string | undefined; @@ -1044,11 +1043,6 @@ function normalizedClientThreadId(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } -function withoutPreviousResponseId(request: Record): Record { - const { previous_response_id: _previousResponseId, ...freshRequest } = request; - return freshRequest; -} - export function expandPreviousResponseInput(body: unknown, clientThreadId?: string): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const request = body as Record; @@ -1068,10 +1062,9 @@ export function expandPreviousResponseInput(body: unknown, clientThreadId?: stri // A Codex task must never inherit another task's continuation, nor a legacy unscoped entry. // Unscoped callers retain backward-compatible replay only with other unscoped entries. if (requestThreadId !== storedThreadId) { - const freshRequest = withoutPreviousResponseId(request); - replayScopeMismatches.add(freshRequest); + replayFailures.set(request, { code: "previous_response_not_found", reason: "scope_mismatch" }); replayScopeMismatchDrops += 1; - return freshRequest; + return body; } // The client already replayed this history verbatim. Prepending the stored copy would // double it, and the doubled turn is stored again, so the next turn triples (#1412 saw @@ -1136,9 +1129,9 @@ export function copyPreviousResponseReplayProvenance(source: unknown, target: un replayedInputPrefixLengths.set(target, prefixLength); } -/** True when a stale or foreign previous_response_id was removed from this exact request body. */ +/** True when this exact request could not replay because its task scope did not match. */ export function previousResponseScopeMismatch(body: unknown): boolean { - return !!body && typeof body === "object" && replayScopeMismatches.has(body as object); + return previousResponseReplayFailure(body)?.reason === "scope_mismatch"; } export function previousResponseConversationId(responseId: string | undefined): string | undefined { diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 8cb50db8210..81b74274991 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -29,7 +29,6 @@ import { import { formatErrorResponse } from "../../bridge"; import { expandPreviousResponseInput, - previousResponseScopeMismatch, previousResponseReplayFailure, previousResponseProviderState, } from "../../responses/state"; @@ -201,11 +200,12 @@ export async function executeComboResponses( // imageInput is disabled (and so targets see the full replayed input). const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; const body = expandPreviousResponseInput(rawBody, inboundClientThreadId); - const scopeMismatch = previousResponseScopeMismatch(body); - if (scopeMismatch) { - console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh"); + const replayFailure = previousResponseReplayFailure(body); + if (replayFailure?.reason === "scope_mismatch") { + console.warn("[opencodex] refusing continuation because the client task scope does not match replay state"); } - if (previousResponseReplayFailure(body)) { + // Local replay failures require full client replay. + if (replayFailure) { return formatErrorResponse( 400, "previous_response_not_found", @@ -235,7 +235,7 @@ export async function executeComboResponses( sourceBody: body, previousResponseInputExpanded: body !== rawBody && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string", - providerContinuation: !scopeMismatch && body !== rawBody && requestedPreviousId + providerContinuation: body !== rawBody && requestedPreviousId ? previousResponseProviderState(requestedPreviousId) : undefined, recoveredPlaintext: false, diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 08f001ac078..bee8357350d 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -41,7 +41,6 @@ import { import { copyPreviousResponseReplayProvenance, expandPreviousResponseInput, - previousResponseScopeMismatch, previousResponseReplayFailure, markBodyNonPersistable, previousResponseProviderState, @@ -243,10 +242,13 @@ export async function prepareResponsesRequest( copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body); } else { body = expandPreviousResponseInput(body, inboundClientThreadId); - if (previousResponseScopeMismatch(body)) { - console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh"); + const replayFailure = previousResponseReplayFailure(body); + if (replayFailure?.reason === "scope_mismatch") { + // Bounded and content-free: no task scope and nothing about the retained entry. + console.warn("[opencodex] refusing continuation because the client task scope does not match replay state"); } - if (previousResponseReplayFailure(body)) { + // Local replay failures require full client replay. + if (replayFailure) { return formatErrorResponse( 400, "previous_response_not_found", @@ -925,7 +927,7 @@ export async function prepareResponsesRequest( return formatErrorResponse( 400, "previous_response_not_found", - "OpenAI forward continuation state is unavailable or expired; resend the full conversation without previous_response_id.", + "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", ); } @@ -951,7 +953,7 @@ export async function prepareResponsesRequest( return formatErrorResponse( 400, "previous_response_not_found", - "Routed continuation requires unavailable local history; resend the full conversation without previous_response_id.", + "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", ); } } diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1d954bcc442..336bdc4bec5 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -480,7 +480,12 @@ receive `previous_response_not_found` before upstream dispatch and must resend c without `previous_response_id`. That refusal is not specific to the stateless flag: it covers every destination that cannot see the prefix this process failed to restore, which is every destination except the native Responses passthrough. The passthrough forwards the id and keeps its -upstream-owned state. `PROVIDER_OWNED_CONTINUATION_WIRES` in +upstream-owned state. A task-scope mismatch uses the same generic refusal even when the supplied +input appears complete, because the proxy cannot prove that it contains the full conversation. +The internal mismatch reason, stored scope and state contents never enter the client response; +the caller retries explicitly with complete history and no `previous_response_id`. Matching +normalized scopes replay, and two absent or blank scopes remain the legacy unscoped cohort. +`PROVIDER_OWNED_CONTINUATION_WIRES` in `src/responses/continuation-ownership.ts` is deliberately empty and records why the three candidates do not qualify: devin re-sends the whole conversation each turn, cursor reads its `checkpointRef` out of the same expired store and otherwise falls back to `full-replay`, and kiro 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 afc233754d0..59657cfd870 100644 --- a/tests/codex-integration/issue-702-expired-replay-state.test.ts +++ b/tests/codex-integration/issue-702-expired-replay-state.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, unlinkSync } from "node:fs"; +import { mkdtempSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; @@ -53,6 +53,19 @@ interface ForwardScenario { type ForwardScenarioMode = "expired" | "fresh" | "ordinary"; +/** + * The one answer every local replay failure gives a client. + * + * Missing, corrupt and task-scope-mismatched state all mean "replay in full", so they share a + * response rather than each describing which one happened. Stated once here so a drift shows up + * as one failing constant instead of several assertions written from memory. + */ +const LOCAL_REPLAY_UNAVAILABLE = { + message: "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", + type: "invalid_request_error", + code: "previous_response_not_found", +} as const; + function forwardConfig(): OcxConfig { return { port: 0, @@ -649,13 +662,7 @@ describe("Issue #702 expired forward replay state", () => { }), }); expect(response.status).toBe(400); - expect(await response.json()).toEqual({ - error: { - message: "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", - type: "invalid_request_error", - code: "previous_response_not_found", - }, - }); + expect(await response.json()).toEqual({ error: LOCAL_REPLAY_UNAVAILABLE }); } finally { await server.stop(true); } @@ -685,11 +692,7 @@ describe("Issue #702 expired forward replay state", () => { expect(scenario.upstreamRequests).toHaveLength(1); expect(scenario.secondStatus).toBe(400); expect(JSON.parse(scenario.secondResponseText)).toMatchObject({ - error: { - message: expect.stringMatching(/continuation state.*expired/i), - type: "invalid_request_error", - code: "previous_response_not_found", - }, + error: LOCAL_REPLAY_UNAVAILABLE, }); }); @@ -786,6 +789,145 @@ describe("Issue #702 expired forward replay state", () => { expect(serialized).toContain(CURRENT_USER_SENTINEL); }); + test("a task-scope mismatch refuses the delta before ordinary HTTP upstream I/O", async () => { + const scenario = await runForwardScenario("fresh", { "x-codex-parent-thread-id": "other-task" }); + + expect(scenario.firstStatus).toBe(200); + expect(scenario.secondStatus).toBe(400); + expect(JSON.parse(scenario.secondResponseText)).toEqual({ error: LOCAL_REPLAY_UNAVAILABLE }); + expect(scenario.upstreamRequests).toHaveLength(1); + }); + + test("missing, corrupt and mismatched local state answer identically on one route", async () => { + // All three mean the same thing to a client: replay in full. Each is produced for real here + // rather than inferred from the source strings - one spill deleted, one overwritten with + // bytes that do not parse, and one entry retained under a different task. + setResponseStateByteCapForTests(1_024); + const stored = async (id: string, threadId?: string): Promise => { + rememberResponseState( + { model: "gpt-5.5", input: "x".repeat(8_000), store: false }, + { id, status: "completed", output: [{ role: "assistant", content: "done" }] }, + undefined, + { force: true, ...(threadId ? { clientThreadId: threadId } : {}) }, + ); + await flushPendingResponseSpillsForTests(); + const spillDir = responseSpillDirectory(testHome); + const spill = readdirSync(spillDir).find(name => name.includes(id)); + expect(spill, `spill for ${id}`).toBeDefined(); + return join(spillDir, spill!); + }; + + const missingPath = await stored("resp_local_missing"); + unlinkSync(missingPath); + const corruptPath = await stored("resp_local_corrupt"); + writeFileSync(corruptPath, "{ this is not valid json", "utf8"); + await stored("resp_local_foreign", "task-a"); + + let upstreamCalls = 0; + globalThis.fetch = (async () => { + upstreamCalls += 1; + throw new Error("upstream must not be called"); + }) as typeof fetch; + saveConfig(forwardConfig()); + const server = startServer(0); + const token = fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" }); + const ask = async (previousResponseId: string, threadId: string): Promise<{ status: number; body: string }> => { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + "chatgpt-account-id": "acct-issue-702", + "x-codex-parent-thread-id": threadId, + }, + body: JSON.stringify({ + model: "gpt-5.5", + previous_response_id: previousResponseId, + input: [inputMessage(CURRENT_USER_SENTINEL)], + stream: true, + store: false, + }), + }); + return { status: response.status, body: await response.text() }; + }; + + try { + const missing = await ask("resp_local_missing", "task-a"); + const corrupt = await ask("resp_local_corrupt", "task-a"); + const mismatch = await ask("resp_local_foreign", "task-b"); + const expected = { error: LOCAL_REPLAY_UNAVAILABLE }; + for (const [label, outcome] of [["missing", missing], ["corrupt", corrupt], ["mismatch", mismatch]] as const) { + expect(outcome.status, label).toBe(400); + expect(JSON.parse(outcome.body), label).toEqual(expected); + } + // Byte-identical, not merely equivalent once parsed. + expect(corrupt.body).toBe(missing.body); + expect(mismatch.body).toBe(missing.body); + expect(upstreamCalls).toBe(0); + } finally { + await server.stop(true); + } + }); + + test("a WebSocket client whose task scope changed is refused and recovers by replaying in full", async () => { + // The existing WebSocket coverage exercises expired and missing state. A scope mismatch is + // the third way local replay becomes unusable, and it has to reach the client as the same + // frame so Codex reconnects with its full input instead of terminating the task. + const upstreamRequests: Record[] = []; + const upstream = Bun.serve({ + port: 0, + async fetch(request) { + upstreamRequests.push(await request.json() as Record); + return new Response(completedSse("resp_scope_ws", "replayed"), { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + let server: ReturnType | null = null; + let socket: WebSocket | null = null; + try { + rememberResponseState( + { input: [inputMessage(HISTORICAL_USER_SENTINEL)], store: false }, + { id: FIRST_RESPONSE_ID, status: "completed", output: [] }, + undefined, + { force: true, clientThreadId: "task-a" }, + ); + saveConfig({ + port: 0, hostname: "127.0.0.1", websockets: true, defaultProvider: "routed-test", + providers: { + "routed-test": { + adapter: "openai-responses", baseUrl: upstream.url.toString(), allowPrivateNetwork: true, + authMode: "key", apiKey: "synthetic-key", defaultModel: "test-model", statelessResponses: true, + }, + }, + } as OcxConfig); + server = startServer(0); + socket = await openResponseSocket(server.url, { "x-codex-parent-thread-id": "task-b" }); + const refused = await sendSocketTurn(socket, { + model: "routed-test/test-model", previous_response_id: FIRST_RESPONSE_ID, + input: [inputMessage(CURRENT_USER_SENTINEL)], store: false, + }); + expect(refused).toMatchObject({ + type: "error", status: 400, + error: { type: "invalid_request_error", code: "previous_response_not_found" }, + }); + expect(upstreamRequests).toHaveLength(0); + + const recovered = await sendSocketTurn(socket, { + model: "routed-test/test-model", + input: [inputMessage(HISTORICAL_USER_SENTINEL), inputMessage(CURRENT_USER_SENTINEL)], + store: false, + }); + expect(recovered.type).toBe("response.completed"); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]!.previous_response_id).toBeUndefined(); + } finally { + socket?.close(); + await server?.stop(true); + await upstream.stop(true); + } + }); + test("forward mode still sends an ordinary request without previous_response_id", async () => { const scenario = await runForwardScenario("ordinary"); @@ -796,6 +938,77 @@ describe("Issue #702 expired forward replay state", () => { expect(JSON.stringify(scenario.upstreamRequests[0]!.body)).toContain(HISTORICAL_USER_SENTINEL); }); + test("a destination that owns continuations still gets an unknown id, but never a foreign one", async () => { + // An id this process cannot resolve is the destination's business, and forwarding it is the + // behavior the case below pins. An id this process CAN resolve, to another task's state, is + // not: forwarding it would continue that task's conversation for a different caller. The + // refusal therefore comes before the native exception, not after it. + const upstreamRequests: Record[] = []; + let upstream: ReturnType | null = null; + let server: ReturnType | null = null; + try { + upstream = Bun.serve({ + port: 0, + async fetch(request) { + upstreamRequests.push(await request.json() as Record); + return new Response(completedSse("resp_native_forwarded", "forwarded"), { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "test-openai", + providers: { + "test-openai": { + adapter: "openai-responses", + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + allowPrivateNetwork: true, + apiKey: "provider-key", + defaultModel: "gpt-5.5", + statelessResponses: false, + }, + }, + } as OcxConfig); + server = startServer(0); + rememberResponseState( + { input: [inputMessage(HISTORICAL_USER_SENTINEL)], store: false }, + { id: FIRST_RESPONSE_ID, status: "completed", output: [] }, + undefined, + { force: true, clientThreadId: "task-a" }, + ); + const send = (previousResponseId: string, threadId: string) => originalFetch( + new URL("/v1/responses", server!.url), + { + method: "POST", + headers: { "content-type": "application/json", "x-codex-parent-thread-id": threadId }, + body: JSON.stringify({ + model: "test-openai/gpt-5.5", + previous_response_id: previousResponseId, + input: [inputMessage(CURRENT_USER_SENTINEL)], + stream: true, + store: false, + }), + }, + ); + + const foreign = await send(FIRST_RESPONSE_ID, "task-b"); + expect(foreign.status).toBe(400); + expect(await foreign.json()).toMatchObject({ error: { code: "previous_response_not_found" } }); + expect(upstreamRequests).toHaveLength(0); + + const unknown = await send("resp_never_seen_here", "task-b"); + expect(unknown.status).toBe(200); + await unknown.text(); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]!.previous_response_id).toBe("resp_never_seen_here"); + } finally { + await server?.stop(true); + await upstream?.stop(true); + } + }); + test.each(["message", "function", "custom"] as const)("API-key Responses providers can still forward native %s continuation state", async kind => { const upstreamRequests: Record[] = []; const realNow = Date.now; diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index c1eaa5770cd..ec801d5109d 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -356,7 +356,7 @@ describe("Responses previous_response_id state", () => { model: "cursor/auto", previous_response_id: first.id, input: [{ type: "function_call_output", call_id: "call_1", output: "ok" }], - }) as { input: unknown[] }; + }, " ") as { input: unknown[] }; expect(expanded.input).toEqual([ { role: "user", content: "use ping" }, @@ -376,13 +376,10 @@ describe("Responses previous_response_id state", () => { model: "cursor/auto", previous_response_id: first.id, input: "continue task A", - }, "task-a") as { previous_response_id?: string; input: unknown[] }; + }, " task-a ") as { previous_response_id?: string; input: unknown[] }; expect(sameTask.previous_response_id).toBe(first.id); - expect(sameTask.input).toEqual([ - { role: "user", content: "private task A history" }, - first.output[0], - { role: "user", content: "continue task A" }, - ]); + expect(sameTask.input).toEqual([{ role: "user", content: "private task A history" }, first.output[0], + { role: "user", content: "continue task A" }]); expect(previousResponseScopeMismatch(sameTask)).toBe(false); const foreignTask = expandPreviousResponseInput({ @@ -390,10 +387,12 @@ describe("Responses previous_response_id state", () => { previous_response_id: first.id, input: "brand-new task B", }, "task-b") as { previous_response_id?: string; input: string }; - expect(foreignTask).toEqual({ model: "cursor/auto", input: "brand-new task B" }); + expect(foreignTask).toEqual({ model: "cursor/auto", previous_response_id: first.id, input: "brand-new task B" }); expect(previousResponseScopeMismatch(foreignTask)).toBe(true); - expect(previousResponseReplayPrefixLength(foreignTask)).toBe(0); - expect(responseStateMetrics().replayScopeMismatchDrops).toBe(1); + expect(previousResponseReplayFailure(foreignTask)?.reason).toBe("scope_mismatch"); + const unscopedTask = expandPreviousResponseInput({ previous_response_id: first.id, input: "unscoped" }); + expect(previousResponseReplayFailure(unscopedTask)?.reason).toBe("scope_mismatch"); + expect(responseStateMetrics().replayScopeMismatchDrops).toBe(2); }); test("scoped tasks reject legacy unscoped continuation state", () => { @@ -406,7 +405,7 @@ describe("Responses previous_response_id state", () => { previous_response_id: first.id, input: "new task", }, "task-new"); - expect(freshTask).toEqual({ input: "new task" }); + expect(freshTask).toEqual({ previous_response_id: first.id, input: "new task" }); expect(previousResponseScopeMismatch(freshTask)).toBe(true); }); @@ -1961,7 +1960,7 @@ describe("Responses previous_response_id state", () => { { previous_response_id: "resp_spill_restart", input: "foreign" }, "task-other", ); - expect(foreign).toEqual({ input: "foreign" }); + expect(foreign).toEqual({ previous_response_id: "resp_spill_restart", input: "foreign" }); }); test("spill references bind the expected response id and use the locked digest basename", () => { diff --git a/tests/server/plaintext-v2-agent-messages-server.test.ts b/tests/server/plaintext-v2-agent-messages-server.test.ts index cfbba40249c..4de9f24bfc1 100644 --- a/tests/server/plaintext-v2-agent-messages-server.test.ts +++ b/tests/server/plaintext-v2-agent-messages-server.test.ts @@ -540,7 +540,8 @@ describe("plaintext v2 agent messages at the Responses server boundary", () => { const secondBody = await second.text(); expect({ status: second.status, body: secondBody, sends: sentBodies.length }).toEqual({ status: 400, - body: expect.stringContaining("continuation state is unavailable or expired"), + // One message for every local replay failure, so which one occurred is not readable here. + body: expect.stringContaining("Continuation state is unavailable or corrupt"), sends: 1, }); }); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 19c344003f8..afbfc5ecacd 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -2444,23 +2444,36 @@ describe("server combo failover 030 activation matrix", () => { }); const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }); const headers = { "x-codex-parent-thread-id": "combo-task" }; - const legacyResponse = await post(config, { previous_response_id: "resp_combo_legacy_unscoped", input: "fresh scoped input", }, {}, headers); + const fullInput = [{ role: "user", content: "complete history" }, { role: "user", content: "current turn" }]; + const fullMismatch = await post(config, { + previous_response_id: "resp_combo_scoped", + input: fullInput, + }, {}, { "x-codex-parent-thread-id": "other-task" }); + const genericError = { + error: { message: "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", + type: "invalid_request_error", code: "previous_response_not_found" }, + }; + expect(legacyResponse.status).toBe(400); + expect(await legacyResponse.json()).toEqual(genericError); + expect(fullMismatch.status).toBe(400); + expect(await fullMismatch.json()).toEqual(genericError); + expect(bodies).toHaveLength(0); + const retried = await post(config, { input: fullInput }, {}, { "x-codex-parent-thread-id": "other-task" }); const scopedResponse = await post(config, { previous_response_id: "resp_combo_scoped", input: "continue scoped task", }, {}, headers); - - expect(legacyResponse.status).toBe(200); - expect(scopedResponse.status).toBe(200); - expect(bodies).toHaveLength(2); - expect(JSON.stringify(bodies[0])).not.toContain("legacy private history"); - expect(JSON.stringify(bodies[0])).toContain("fresh scoped input"); + const legacyUnscoped = await post(config, { previous_response_id: "resp_combo_legacy_unscoped", + input: "legacy continuation" }); + expect([retried.status, scopedResponse.status, legacyUnscoped.status]).toEqual([200, 200, 200]); + expect(bodies).toHaveLength(3); + expect(JSON.stringify(bodies[0])).toContain("complete history"); expect(JSON.stringify(bodies[1])).toContain("scoped private history"); - expect(JSON.stringify(bodies[1])).toContain("continue scoped task"); + expect(JSON.stringify(bodies[2])).toContain("legacy private history"); }); test("combo child preserves replay provenance for compaction and generated guidance", async () => {