From 82c8dcb9178b2babd132c64c3476a90a8965fafb Mon Sep 17 00:00:00 2001 From: mose Date: Mon, 17 Aug 2026 19:39:49 +0900 Subject: [PATCH 1/2] fix(cursor): reuse Connect conversations from session_id store:false Responses clients such as GJC never send x-codex-parent-thread-id or previous_response_id, so every turn minted a new Cursor conversation and Connect burst-rejected claude-fable-5. Fall back to session_id / session-id for the same thread key. --- src/server/request-log-conversation.ts | 7 +++++++ src/server/responses/core.ts | 3 ++- tests/request-log-conversation.test.ts | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 7cef86ab47..63b1a6027f 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -61,6 +61,13 @@ export function sessionIdHeaderFromRequest(headers: Headers): string | null { return headers.get("session_id") ?? headers.get("session-id"); } +/** Cursor conversation reuse: Codex parent thread, then Responses session headers. */ +export function clientThreadIdFromResponsesHeaders(headers: Headers): string | undefined { + return headers.get("x-codex-parent-thread-id")?.trim() + || sessionIdHeaderFromRequest(headers)?.trim() + || undefined; +} + export function conversationIdFromResponsesRequest(input: { clientThreadId?: string; sessionIdHeader?: string | null; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2df5160984..85d50787b5 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, @@ -1654,7 +1655,7 @@ async function handleResponsesInner( let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( (body as { input?: unknown } | undefined)?.input, ); - const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; + const inboundClientThreadId = clientThreadIdFromResponsesHeaders(req.headers); 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..59556ddc34 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, @@ -70,6 +71,20 @@ describe("sessionIdHeaderFromRequest", () => { }))).toBe("underscore"); }); }); +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"); + }); +}); describe("conversationIdFromResponsesRequest", () => { test("prefers parent thread header over session / thread / cursor", () => { From e474ec3e4fd19bf5a68a13eb83cbb4a8ecdd6538 Mon Sep 17 00:00:00 2001 From: mose Date: Mon, 17 Aug 2026 19:50:03 +0900 Subject: [PATCH 2/2] fix(cursor): skip blank session_id and reuse combo thread scope Blank session_id no longer masks session-id. Combo continuations now expand previous_response_id with the same inbound thread key. --- src/server/request-log-conversation.ts | 13 ++++++++++--- src/server/responses/core.ts | 7 ++++--- tests/request-log-conversation.test.ts | 12 ++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 63b1a6027f..c70f96e27a 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -58,16 +58,23 @@ 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 headers.get("x-codex-parent-thread-id")?.trim() - || sessionIdHeaderFromRequest(headers)?.trim() + 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: { clientThreadId?: string; sessionIdHeader?: string | null; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 85d50787b5..2a5ebe7ef5 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1247,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 @@ -1264,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, @@ -1642,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?.(); @@ -1650,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 = clientThreadIdFromResponsesHeaders(req.headers); 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 59556ddc34..ad18dd51de 100644 --- a/tests/request-log-conversation.test.ts +++ b/tests/request-log-conversation.test.ts @@ -69,8 +69,13 @@ 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({ @@ -84,6 +89,13 @@ describe("clientThreadIdFromResponsesHeaders", () => { 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"); + }); }); describe("conversationIdFromResponsesRequest", () => {