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: 6 additions & 3 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErro
import { isCursorExternalWireModel } from "./cursor/discovery";
import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
import { mapCursorServerMessage } from "./cursor/message-mapper";
import { createCursorRequest, generatedCursorConversationId } from "./cursor/request-builder";
import { createCursorRequest } from "./cursor/request-builder";
import {
createLiveCursorTransport,
CursorMissingCredentialError,
Expand Down Expand Up @@ -77,12 +77,15 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
const makeTransport = deps.createTransport ?? createLiveCursorTransport;
const kv = deps.kv ?? createCursorKvStore();
const rekeyContextUsage = deps.rekeyContextUsage ?? rekeyCursorContextUsage;
_parsed._cursorConversationId ??= generatedCursorConversationId();
// Do NOT pre-mint a random conversation id here. createCursorRequest resolves
// remembered id → native prompt_cache_key pin → random. Pre-minting would hide
// the prompt_cache_key pin and break Cursor cache/context carry-forward when
// Codex sends full history under store:false without previous_response_id.
const previousConversationId = _parsed._cursorConversationId;
let request = createCursorRequest(_parsed);
// Keep remembered conversation id in sync when the request builder mints a fresh id
// for external-model tool-result continuations (stateless replay).
if (request.conversationId !== previousConversationId) {
if (previousConversationId && request.conversationId !== previousConversationId) {
rekeyContextUsage(previousConversationId, request.conversationId);
}
_parsed._cursorConversationId = request.conversationId;
Expand Down
58 changes: 43 additions & 15 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import type {
OcxAssistantContentPart,
OcxContentPart,
Expand All @@ -8,7 +9,7 @@ import type {
} from "../../types";
import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
import type { CursorRequestMessage, CursorRunRequest } from "./types";
import { cursorWireModelSelection, isCursorExternalWireModel, type CursorRoutingLevel } from "./discovery";
import { cursorWireModelSelection, isCursorExternalWireModel, isCursorNativeWireModel, type CursorRoutingLevel } from "./discovery";
import { cursorEffortSuffix } from "./effort-map";
import {
cursorMcpToolEncodedSize,
Expand Down Expand Up @@ -164,6 +165,46 @@ export interface CreateCursorRequestOptions {
forceFreshConversation?: boolean;
}

/**
* Stable Cursor conversation id derived from Codex's prompt_cache_key (thread id).
* Used when previous_response_id continuation state is missing but the client still
* replays full history under store:false — without this, every turn mints a fresh
* cursor_* id and Cursor prompt-cache / context-usage carry-forward never hit.
*/
export function stableCursorConversationIdFromPromptCacheKey(promptCacheKey: string): string {
const digest = createHash("sha256")
.update("ocx-cursor-conv:")
.update(promptCacheKey)
.digest("hex")
.slice(0, 32);
return `cursor_${digest}`;
Comment on lines +174 to +180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not treat prompt_cache_key as a conversation ID.

Lines 174-180 deterministically map every native request sharing a cache key to the same Cursor conversation. prompt_cache_key is documented for cache bucketing of similar requests, not as a unique conversation identifier; its predecessor semantics describe a stable end-user identifier. (platform.openai.com) Two independent store:false conversations for the same user/key can therefore inherit each other’s Cursor context/checkpoints.

Carry a distinct, upstream conversation/thread identifier through the request and derive the Cursor ID from that (scoped to the provider/client). When no such identifier exists, retain fresh-ID behavior rather than persisting conversation state from a cache key. Update tests/cursor-request-builder.test.ts Lines 41-69 to cover distinct conversations sharing a cache key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/cursor/request-builder.ts` around lines 174 - 180, The
stableCursorConversationIdFromPromptCacheKey flow incorrectly derives shared
conversation state from promptCacheKey. Carry a distinct upstream
conversation/thread identifier through request construction, derive the
provider/client-scoped Cursor ID from that identifier, and preserve fresh-ID
behavior when it is absent; remove promptCacheKey as the conversation identity
source. Update cursor request-builder tests to verify distinct conversations
with the same cache key remain isolated.

}

/**
* Resolve the Cursor conversation id for this turn.
* Priority: force-fresh → remembered `_cursorConversationId` → native+prompt_cache_key → random.
* Never use OpenAI Responses `previous_response_id` (resp_*) — different namespace.
*/
export function resolveCursorConversationId(
parsed: OcxParsedRequest,
wireModelId: string,
options: CreateCursorRequestOptions = {},
): string {
const lastRaw = parsed.context.messages.at(-1);
const forceFreshConversation =
options.forceFreshConversation === true
|| (lastRaw?.role === "toolResult" && isCursorExternalWireModel(wireModelId));
if (forceFreshConversation) return generatedCursorConversationId();
if (parsed._cursorConversationId) return parsed._cursorConversationId;
// Native composer/auto: pin to the Codex thread so full-history turns without
// previous_response_id still share one Cursor conversation (cache + checkpoints).
if (isCursorNativeWireModel(wireModelId)) {
const key = parsed.options.promptCacheKey?.trim();
if (key) return stableCursorConversationIdFromPromptCacheKey(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid pinning Cursor conversations to shared cache keys

On the Claude /v1/messages path when metadata.user_id is absent (Claude Desktop), the translator still creates prompt_cache_key from only the model/system/tools cache cohort, and src/server/claude-messages.ts:631-638 explicitly treats that fallback as shared across Desktop conversations rather than a session id. Returning a Cursor conversation id for any non-empty promptCacheKey here means unrelated Desktop chats routed to native Cursor models (composer-*/default) can send the same cursor_<hash> and resume/share Cursor server conversation state instead of only cache affinity. Please carry cache-key provenance through parsing or otherwise skip synthesized shared keys before using them as conversation identity.

Useful? React with 👍 / 👎.

}
return generatedCursorConversationId();
}

export function createCursorRequest(
parsed: OcxParsedRequest,
options: CreateCursorRequestOptions = {},
Expand All @@ -176,23 +217,10 @@ export function createCursorRequest(
const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
const limitNote = catalogLimitNote(budget.tools, budget.omitted);
const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning);
const lastRaw = parsed.context.messages.at(-1);
// External Cursor models (e.g. gpt-5.6-sol) can corrupt server-side conversation state across
// tool-result continuations when ResumeAction reuses the same conversationId. Force a fresh id
// so the full history is replayed without depending on that state.
const forceFreshConversation =
options.forceFreshConversation === true
|| (lastRaw?.role === "toolResult" && isCursorExternalWireModel(model.modelId));
return {
modelId: model.modelId,
...(model.routingLevel ? { routingLevel: model.routingLevel } : {}),
// The Cursor conversation id comes ONLY from remembered state (_cursorConversationId). Do NOT fall
// back to the OpenAI Responses previous_response_id (resp_*): that is a Responses-chain id in a
// different namespace and would start an unrelated Cursor conversation, breaking tool-result
// continuation. If we have no remembered Cursor conversation, start a fresh one.
conversationId: forceFreshConversation
? generatedCursorConversationId()
: (parsed._cursorConversationId ?? generatedCursorConversationId()),
conversationId: resolveCursorConversationId(parsed, model.modelId, options),
system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])],
messages,
rawMessages: parsed.context.messages,
Expand Down
62 changes: 62 additions & 0 deletions tests/cursor-request-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,68 @@ describe("Cursor request builder", () => {
expect(request.conversationId).toBe("cursor_stable");
});

test("native composer pins conversation id to prompt_cache_key when none remembered", () => {
const a = createCursorRequest({
modelId: "cursor/composer-2.5",
context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] },
stream: false,
options: { promptCacheKey: "thread-abc" },
});
const b = createCursorRequest({
modelId: "composer-2.5",
context: {
messages: [
{ role: "user", content: "hi", timestamp: 1 },
{
role: "toolResult",
toolCallId: "call_1",
toolName: "shell",
content: "ok",
isError: false,
timestamp: 2,
},
],
},
stream: false,
options: { promptCacheKey: "thread-abc" },
});
expect(a.conversationId).toBe(b.conversationId);
expect(a.conversationId.startsWith("cursor_")).toBe(true);
expect(a.conversationId).toHaveLength("cursor_".length + 32);
});

test("external toolResult still force-fresh even with prompt_cache_key", () => {
const first = createCursorRequest({
modelId: "cursor/grok-4.5",
context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] },
stream: false,
options: { promptCacheKey: "thread-xyz" },
_cursorConversationId: "cursor_prior",
});
expect(first.conversationId).toBe("cursor_prior");

const second = createCursorRequest({
modelId: "cursor/grok-4.5",
context: {
messages: [
{
role: "toolResult",
toolCallId: "call_1",
toolName: "shell",
content: "ok",
isError: false,
timestamp: 2,
},
],
},
stream: false,
options: { promptCacheKey: "thread-xyz" },
_cursorConversationId: "cursor_prior",
});
expect(second.conversationId).not.toBe("cursor_prior");
expect(second.conversationId.startsWith("cursor_")).toBe(true);
});

test("marks Cursor context-usage boundaries for compaction epochs", () => {
expect(createCursorRequest({ ...base, _contextCompactionBoundary: true }).contextUsageReset).toBe(true);

Expand Down
79 changes: 79 additions & 0 deletions tests/server-combo-failover-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1555,4 +1555,83 @@ describe("cursor conversation continuity across store:false chains", () => {
const { previousResponseProviderState } = await import("../src/responses/state");
expect(previousResponseProviderState(secondJson.id)?.cursor?.conversationId).toBe(seen[1]);
});

test("native composer-2.5 toolResult stream chain reuses conversationId", async () => {
const seen: string[] = [];
customCursorTransportFactory = () => ({
async *run(request) {
seen.push(request.conversationId);
if (seen.length === 1) {
yield { type: "tool_call_start", id: "call_1", name: "shell" };
yield { type: "tool_call_delta", arguments: "{}" };
yield { type: "tool_call_end", id: "call_1" };
} else {
yield { type: "text", text: "done" };
}
yield { type: "done", usage: { inputTokens: 10, outputTokens: 2, estimated: true } };
},
writeClient() {},
close() {},
});
const config = cursorConfig();

const postStream = async (raw: Record<string, unknown>) =>
handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ stream: true, store: false, ...raw }),
}), config, { model: "", provider: "" }, {});

const first = await postStream({ model: "cursortest/composer-2.5", input: "hello" });
expect(first.status).toBe(200);
const text1 = await first.text();
const completed = [...text1.matchAll(/event: response\.completed\ndata: ({.*})\n/g)]
.map(match => JSON.parse(match[1]!) as { response?: { id?: string }; id?: string });
const firstId = completed.at(-1)?.response?.id ?? completed.at(-1)?.id;
expect(typeof firstId).toBe("string");
expect(seen).toHaveLength(1);

const { previousResponseProviderState } = await import("../src/responses/state");
expect(previousResponseProviderState(firstId!)?.cursor?.conversationId).toBe(seen[0]);

const second = await postStream({
model: "cursortest/composer-2.5",
previous_response_id: firstId,
input: [{ type: "function_call_output", call_id: "call_1", output: "ok" }],
});
expect(second.status).toBe(200);
await second.text();
expect(seen).toHaveLength(2);
expect(seen[1]).toBe(seen[0]);
});

test("native composer reuses conversationId across store:false turns via prompt_cache_key alone", async () => {
const seen: string[] = [];
customCursorTransportFactory = fakeCursorTransportFactory(seen);
const config = cursorConfig();

const first = await postCursor(config, {
model: "cursortest/composer-2.5",
input: "hello",
prompt_cache_key: "desktop-thread-1",
});
expect(first.status).toBe(200);
await first.json();
expect(seen).toHaveLength(1);

// No previous_response_id — Codex Desktop often replays full history under store:false.
const second = await postCursor(config, {
model: "cursortest/composer-2.5",
input: [
{ role: "user", content: "hello" },
{ role: "assistant", content: "cursor ok" },
{ role: "user", content: "continue" },
],
prompt_cache_key: "desktop-thread-1",
});
expect(second.status).toBe(200);
await second.json();
expect(seen).toHaveLength(2);
expect(seen[1]).toBe(seen[0]);
});
});
Loading