Skip to content
Merged
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ko/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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는 매 턴 요청에 담긴 입력만으로 대화를 다시 구성하므로,
Expand Down
17 changes: 5 additions & 12 deletions src/responses/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,10 @@ export type ResidentInput = Omit<ResidentResponseState, "kind" | "sizeBytes">;

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<string, StoredResponseState>();
const replayScopeMismatches = new WeakSet<object>();
let storedResponseBytes = 0;
let residentResponseBytes = 0;
let oldestResidentId: string | undefined;
Expand Down Expand Up @@ -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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
Expand All @@ -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" });
Comment thread
lidge-jun marked this conversation as resolved.
Resolved
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
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions src/server/responses/core-combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
import { formatErrorResponse } from "../../bridge";
import {
expandPreviousResponseInput,
previousResponseScopeMismatch,
previousResponseReplayFailure,
previousResponseProviderState,
} from "../../responses/state";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 8 additions & 6 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import {
import {
copyPreviousResponseReplayProvenance,
expandPreviousResponseInput,
previousResponseScopeMismatch,
previousResponseReplayFailure,
markBodyNonPersistable,
previousResponseProviderState,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
);
}

Expand All @@ -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.",
);
}
}
Expand Down
7 changes: 6 additions & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading