Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,15 @@ provider advertises `supports_websockets = true` only when `"websockets": true`;
built-in provider may try WebSocket first, and a disabled proxy returns `426` so Codex falls back to
HTTP/SSE.

If a canonical ChatGPT forward continuation references expired or missing local replay state,
opencodex returns `previous_response_not_found` before sending anything upstream. Codex's
WebSocket client recognizes this error and can reconnect with its full retained context,
including completed tool calls and their results, within its normal stream retry budget. An
idle task therefore does not need a new task solely because the proxy's one-hour cache expired.
The cache remains bounded; this does not extend retention or recover history the client no
longer has. HTTP clients must handle the error explicitly and resend their full context without
`previous_response_id`. Retrying only the same ID cannot recover missing state.

### Authless Codex Desktop (opt-in)

Codex Desktop shows its ChatGPT login screen whenever the active provider requires OpenAI auth. If
Expand Down
9 changes: 9 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 @@ -119,6 +119,15 @@ Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들

전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고합니다. loopback에서는 Codex의 빌트인 provider가 먼저 WebSocket을 시도할 수 있으며, 비활성화된 proxy는 `426`을 반환해서 Codex가 HTTP/SSE로 fallback합니다.

네이티브 ChatGPT forward 요청의 로컬 재생 상태가 만료되었거나 없으면 opencodex는
upstream 요청 전에 `previous_response_not_found`를 반환합니다. Codex WebSocket 클라이언트는
일반 스트림 재시도 한도 안에서 다시 연결하고, 완료된 도구 호출과 결과를 포함한 현재 보유
컨텍스트 전체를 다시 보낼 수 있습니다. 따라서 프록시의 1시간 캐시가 만료되었다는 이유만으로
새 작업을 만들 필요는 없습니다. 캐시 한도와 보존 기간은 그대로이며, 클라이언트가 더 이상
보유하지 않는 기록을 복구하는 기능은 아닙니다. HTTP 클라이언트는 이 오류를 직접 처리하고
`previous_response_id` 없이 전체 컨텍스트를 다시 보내야 합니다. 같은 ID만 재시도해서는
누락된 상태를 복구할 수 없습니다.

## 스레드 식별자와 대화 기록

기본 loopback 형식은 새 thread에 네이티브 `openai` provider 태그를 유지하므로 일반적인 resume history는 다시 매핑할 필요가 없습니다. sync와 restore는 일치하는 백업 manifest만 적용하여 각 thread의 원래 provider, source, event marker를 정확히 복원합니다. manifest가 없는 `opencodex` row는 변경하지 않으며, legacy 재태깅을 명시적으로 강제하려는 경우에만 `ocx recover-history --legacy-openai --yes`를 사용합니다. 이 명령은 의도적으로 범위가 넓습니다. 사용자 메시지가 있고 현재 `opencodex`로 표시된 모든 thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 사용하세요. non-loopback 전용 provider 모드는 활성 상태일 때만 history를 `opencodex` provider 아래로 미러링하고, 종료할 때는 백업된 메타데이터를 복원합니다. history를 건드리지 않으려면 `syncResumeHistory: false`로 설정하세요.
Expand Down
6 changes: 4 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3583,14 +3583,16 @@ async function handleResponsesInner(
// The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
// safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
// I/O instead of stripping the id and silently forwarding a context-free delta (#702).
// Codex recognizes previous_response_not_found on WebSocket errors and reconnects with its
// full input. A generic invalid_request_error instead terminates the task after cache expiry.
if (
hasUnexpandedPreviousResponse
&& isCanonicalOpenAiForwardProvider(route.provider)
) {
return formatErrorResponse(
400,
"invalid_request_error",
"OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
"previous_response_not_found",
"OpenAI forward continuation state is unavailable or expired; resend the full conversation without previous_response_id.",
);
}

Expand Down
119 changes: 117 additions & 2 deletions tests/codex-integration/issue-702-expired-replay-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { startServer } from "../../src/server";
import type { OcxConfig } from "../../src/types";
import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt";
import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home";
import { SERVER_BUDGET_MS } from "../helpers/test-budget";
import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget";
import { removeTreeWithRetry } from "../helpers/remove-tree";

const originalFetch = globalThis.fetch;
Expand Down Expand Up @@ -104,6 +104,46 @@ function completedSse(responseId: string, text: string): string {
].join("\n");
}

async function openResponseSocket(url: URL, headers: Record<string, string>): Promise<WebSocket> {
const target = new URL("/v1/responses", url);
target.protocol = "ws:";
const socket = new WebSocket(target, { headers } as unknown as string[]);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
socket.close();
reject(new Error("response socket did not open"));
}, INTERNAL_DEADLINE_MS);
socket.onopen = () => { clearTimeout(timer); resolve(); };
socket.onerror = () => { clearTimeout(timer); reject(new Error("response socket failed to open")); };
});
return socket;
}

async function sendSocketTurn(socket: WebSocket, body: Record<string, unknown>): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const finish = (error?: Error, frame?: Record<string, unknown>) => {
clearTimeout(timer);
socket.onmessage = socket.onclose = socket.onerror = null;
if (error) reject(error);
else resolve(frame!);
};
const timer = setTimeout(() => finish(new Error("response socket did not reach a terminal event")), INTERNAL_DEADLINE_MS);
socket.onclose = () => finish(new Error("response socket closed before its terminal event"));
socket.onerror = () => finish(new Error("response socket failed"));
socket.onmessage = event => {
try {
const frame = JSON.parse(String(event.data));
if (["error", "response.completed", "response.failed", "response.incomplete"].includes(frame.type)) {
finish(undefined, frame);
}
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
};
socket.send(JSON.stringify({ type: "response.create", ...body }));
});
}

async function waitForRecordedResponseState(): Promise<ResponseStateMetrics> {
const deadline = performance.now() + 1_000;
while (performance.now() < deadline) {
Expand Down Expand Up @@ -364,11 +404,86 @@ describe("Issue #702 expired forward replay state", () => {
error: {
message: expect.stringMatching(/continuation state.*expired/i),
type: "invalid_request_error",
code: "invalid_request_error",
code: "previous_response_not_found",
},
});
});

test.each(["expired", "missing"] as const)("%s forward state lets a WebSocket client reconnect and replay full tool history", async mode => {
const upstreamRequests: Record<string, unknown>[] = [];
const realNow = Date.now;
let server: ReturnType<typeof startServer> | null = null;
let socket: WebSocket | null = null;
const toolCall = {
type: "function_call", id: "fc_issue_702", call_id: "call_issue_702",
name: "lookup", arguments: '{"key":"historical"}', status: "completed",
};
const toolResult = {
type: "function_call_output", call_id: "call_issue_702", output: "historical tool result",
};
const history = [inputMessage(HISTORICAL_USER_SENTINEL), toolCall];
const delta = [toolResult, inputMessage(CURRENT_USER_SENTINEL)];
try {
if (mode === "expired") {
Date.now = () => realNow() - EXPIRED_AGE_MS;
rememberResponseState(
{ input: [history[0]], store: false },
{ id: FIRST_RESPONSE_ID, status: "completed", output: [toolCall] },
undefined,
{ force: true },
);
Date.now = realNow;
expect(responseStateMetrics().oldestAgeMs).toBeGreaterThan(REPLAY_TTL_MS);
}
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.hostname === "chatgpt.com" && url.pathname === "/backend-api/codex/responses") {
upstreamRequests.push(JSON.parse(String(init?.body)));
return new Response(completedSse("resp_issue_702_recovered", "recovered with full history"), {
headers: { "content-type": "text/event-stream" },
});
}
return originalFetch(input, init);
}) as typeof fetch;
saveConfig({ ...forwardConfig(), websockets: true });
server = startServer(0);
const headers = {
authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "acct-issue-702" })}`,
"chatgpt-account-id": "acct-issue-702",
};
socket = await openResponseSocket(server.url, headers);
const rejected = await sendSocketTurn(socket, {
model: "gpt-5.5", previous_response_id: FIRST_RESPONSE_ID, input: delta, store: false,
});
expect(rejected).toMatchObject({
type: "error", status: 400,
error: { type: "invalid_request_error", code: "previous_response_not_found" },
});
expect(upstreamRequests).toHaveLength(0);

// Codex recognizes this code, discards its incremental socket state, and reconnects
// with its complete input. The rejected delta must never be forwarded on its own.
socket.close();
socket = await openResponseSocket(server.url, headers);
const recovered = await sendSocketTurn(socket, {
model: "gpt-5.5", input: [...history, ...delta], store: false,
tools: [{ type: "function", name: "lookup", parameters: { type: "object" } }],
});
expect(recovered).toMatchObject({ type: "response.completed", response: { id: "resp_issue_702_recovered" } });
expect(upstreamRequests).toHaveLength(1);
expect(upstreamRequests[0]!.previous_response_id).toBeUndefined();
// The canonical forward adapter removes item ids, but must preserve the call/result
// identity and every input item exactly once when the client supplies full history.
const { id: _itemId, ...forwardedToolCall } = toolCall;
expect(upstreamRequests[0]!.input).toEqual([history[0], forwardedToolCall, ...delta]);
} finally {
Date.now = realNow;
globalThis.fetch = originalFetch;
socket?.close();
await server?.stop(true);
}
}, SERVER_BUDGET_MS);

test("forward mode expands fresh replay state before continuing upstream", async () => {
const scenario = await runForwardScenario("fresh");

Expand Down
Loading