diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 7cef86ab47..c70f96e27a 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -58,7 +58,21 @@ export function matchesLogConversationId( * parent thread header > session_id / session-id > thread-id > cursor conversation id. */ export function sessionIdHeaderFromRequest(headers: Headers): string | null { - return headers.get("session_id") ?? headers.get("session-id"); + return firstNonEmptyHeader(headers, "session_id", "session-id"); +} + +/** Cursor conversation reuse: Codex parent thread, then Responses session headers. */ +export function clientThreadIdFromResponsesHeaders(headers: Headers): string | undefined { + return firstNonEmptyHeader(headers, "x-codex-parent-thread-id", "session_id", "session-id") + || undefined; +} + +function firstNonEmptyHeader(headers: Headers, ...names: string[]): string | null { + for (const name of names) { + const value = headers.get(name)?.trim(); + if (value) return value; + } + return null; } export function conversationIdFromResponsesRequest(input: { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2df5160984..2a5ebe7ef5 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -173,6 +173,7 @@ import { type RequestLogContext, } from "../request-log"; import { + clientThreadIdFromResponsesHeaders, conversationIdFromResponsesRequest, normalizeLogConversationId, sessionIdHeaderFromRequest, @@ -1246,6 +1247,7 @@ export async function handleComboResponses( config: OcxConfig, logCtx: RequestLogContext, options: HandleResponsesOptions, + inboundClientThreadId = clientThreadIdFromResponsesHeaders(req.headers), ): Promise { const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string" ? (rawBody as { model: string }).model @@ -1263,7 +1265,7 @@ export async function handleComboResponses( // Expand previous_response_id before image policy and child dispatch so a // continuation that only references prior images still fails closed when // imageInput is disabled (and so targets see the full replayed input). - const body = expandPreviousResponseInput(rawBody); + const body = expandPreviousResponseInput(rawBody, inboundClientThreadId); if (previousResponseReplayFailure(body)) { return formatErrorResponse( 400, @@ -1641,6 +1643,7 @@ async function handleResponsesInner( } return decodeRequestErrorResponse(err, "responses"); } + const inboundClientThreadId = clientThreadIdFromResponsesHeaders(req.headers); const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null; if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { options.onRequestBodyRead?.(); @@ -1649,12 +1652,11 @@ async function handleResponsesInner( // The original request body was accepted above. Combo children are synthetic // replays and must not repeat the caller-owned timeout transition. onRequestBodyRead: undefined, - }); + }, inboundClientThreadId); } let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( (body as { input?: unknown } | undefined)?.input, ); - const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; const originalBody = body; body = expandPreviousResponseInput(body, inboundClientThreadId); if (previousResponseScopeMismatch(body)) { diff --git a/tests/request-log-conversation.test.ts b/tests/request-log-conversation.test.ts index 0a086498f6..ad18dd51de 100644 --- a/tests/request-log-conversation.test.ts +++ b/tests/request-log-conversation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { + clientThreadIdFromResponsesHeaders, conversationIdFromClaudeCacheKey, conversationIdFromClaudeMetadata, conversationIdFromResponsesRequest, @@ -68,6 +69,32 @@ describe("sessionIdHeaderFromRequest", () => { session_id: "underscore", "session-id": "hyphen", }))).toBe("underscore"); + expect(sessionIdHeaderFromRequest(new Headers({ + session_id: " ", + "session-id": "hyphen", + }))).toBe("hyphen"); + }); +}); + +describe("clientThreadIdFromResponsesHeaders", () => { + test("prefers Codex parent thread over session_id", () => { + expect(clientThreadIdFromResponsesHeaders(new Headers({ + "x-codex-parent-thread-id": "parent", + session_id: "session", + }))).toBe("parent"); + }); + + test("falls back to session_id so store:false clients reuse Cursor conversations", () => { + expect(clientThreadIdFromResponsesHeaders(new Headers({ + session_id: "gjc-session", + }))).toBe("gjc-session"); + }); + + test("falls back to session-id when session_id is blank", () => { + expect(clientThreadIdFromResponsesHeaders(new Headers({ + session_id: " ", + "session-id": "hyphen-session", + }))).toBe("hyphen-session"); }); });