Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
headers: incoming.headers,
translatorBudget: incoming.translatorBudget,
requestDeclaresFullAccess: cursorRequestDeclaresFullAccess(activeRequest),
sessionId: activeRequest.conversationId,
},
activeRequest,
incoming.abortSignal,
Expand Down
11 changes: 7 additions & 4 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,9 +438,12 @@ class LiveCursorTransport implements CursorTransport {
private firstFrameAt?: number;
private firstFrameLogged = false;
/** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */
private readonly sessionId = crypto.randomUUID();
private readonly sessionId: string;
/** Per-transport owner for native-exec / background shells. Must not share conversationId. */
private readonly shellOwnerId = crypto.randomUUID();

constructor(private readonly input: CursorTransportFactoryInput) {
this.sessionId = input.sessionId?.trim() || crypto.randomUUID();
this.translatorBudget = input.translatorBudget;
this.token = resolveCursorToken(input.provider, input.headers);
// Grace window before a drained client-tool turn is finalized. Small enough not to look like a
Expand All @@ -452,7 +455,7 @@ class LiveCursorTransport implements CursorTransport {
this.desktopDeps = desktopDepsFromConfig(input.provider.desktopExecutor);
this.execContext = {
...this.desktopDeps,
sessionId: this.sessionId,
sessionId: this.shellOwnerId,
unsafeAllowNativeLocalExec: effectiveCursorNativeExecAllow(input.provider, input.requestDeclaresFullAccess === true),
};
const servers = resolveMcpServers(input.provider);
Expand Down Expand Up @@ -487,7 +490,7 @@ class LiveCursorTransport implements CursorTransport {
...this.desktopDeps,
...mcpDepsFromManager(this.mcpManager!),
mcpToolDefs,
sessionId: this.sessionId,
sessionId: this.shellOwnerId,
unsafeAllowNativeLocalExec: effectiveCursorNativeExecAllow(this.input.provider, this.input.requestDeclaresFullAccess === true),
};
} catch (err) {
Expand Down Expand Up @@ -700,7 +703,7 @@ class LiveCursorTransport implements CursorTransport {
}

private startShellCleanup(): Promise<BackgroundShellTerminationReport> {
return this.shellCleanup ??= terminateBackgroundShellsForSession(this.sessionId);
return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId);
}

async close(): Promise<void> {
Expand Down
22 changes: 18 additions & 4 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192;
/** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */
export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024;

/**
* Action text for external-model tool-result continuations. Native models keep
* resumeAction; external wire models continue as userMessageAction so the
* results already stored in history blobs are visible without a ResumeAction.
*/
export const CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT =
"Continue: the requested tool results are provided in the conversation history above.";

/** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */
function runtimeTimeZone(): string {
try {
Expand Down Expand Up @@ -731,18 +739,24 @@ function buildPreparedCursorRunRequest(
const text = lastRole === "user" || lastRole === "developer"
? appendCursorGenericToolUseHint(request.tools, rawText)
: rawText;
// Tool-result-only turns resume the remembered Cursor conversation with results in history.
const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult";
const actionCase = !lastRawIsToolResult && text.trim().length > 0
// Native models resume the remembered Cursor conversation. External wire
// models continue as userMessageAction so history-blob tool results stay
// visible without a ResumeAction.
const externalToolContinuation = lastRawIsToolResult && isCursorExternalWireModel(request.modelId);
const actionCase = (externalToolContinuation || (!lastRawIsToolResult && text.trim().length > 0))
? "userMessageAction"
: "resumeAction";
const actionText = externalToolContinuation
? CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT
: text;
const action = create(ConversationActionSchema, {
action: actionCase === "userMessageAction"
? {
case: "userMessageAction",
value: create(UserMessageActionSchema, {
userMessage: create(UserMessageSchema, {
text,
text: actionText,
messageId: crypto.randomUUID(),
}),
requestContext: buildRequestContext(),
Expand Down Expand Up @@ -842,7 +856,7 @@ function buildPreparedCursorRunRequest(
// tools the payload dropped — the defect that blocked PR #376.
const modelVisibleParts = [
...rootPromptMessagesState.serialized,
...(actionCase === "userMessageAction" ? [text] : []),
...(actionCase === "userMessageAction" ? [actionText] : []),
...mcpToolDefs.map(modelVisibleToolText),
];
return {
Expand Down
5 changes: 5 additions & 0 deletions src/adapters/cursor/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export interface CursorTransportFactoryInput {
* native local exec authorization because the text is caller-controlled.
*/
requestDeclaresFullAccess?: boolean;
/**
* Stable Cursor Connect `x-session-id` across transport rebuilds for the same
* client thread. Distinct from the per-transport native-exec/shell owner.
*/
sessionId?: string;
}

export type CursorTransportFactory = (input: CursorTransportFactoryInput) => CursorTransport;
Expand Down
42 changes: 42 additions & 0 deletions tests/cursor-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "../src/adapters/cursor/thread-continuity";
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types";
import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "../src/adapters/cursor/types";
import type { CursorTransportFactoryInput } from "../src/adapters/cursor/transport";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const createCursorAdapter = (...args: Parameters<typeof createCursorAdapterProduction>) =>
Expand Down Expand Up @@ -162,6 +163,47 @@ describe("Cursor adapter live transport", () => {
expect(ids).toHaveLength(2);
expect(ids[0]).not.toBe(ids[1]);
});
test("passes conversationId as Connect sessionId and isolates helper turns", async () => {
const captured: CursorTransportFactoryInput[] = [];
const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, {
createTransport(input) {
captured.push(input);
return {
async *run() {
yield { type: "done" } satisfies CursorServerMessage;
},
writeClient() {},
};
},
});

const parent: OcxParsedRequest = {
modelId: "cursor/auto",
context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] },
stream: false,
options: {},
_clientThreadId: "parent-thread-session-id",
};
await adapter.runTurn?.(parent, { headers: new Headers() }, () => {});
expect(captured).toHaveLength(1);
expect(captured[0]?.sessionId).toBeTruthy();
expect(captured[0]?.sessionId).toBe(parent._cursorConversationId);

const helper: OcxParsedRequest = {
modelId: "cursor/auto",
context: { messages: [{ role: "user", content: "summarize", timestamp: 1 }] },
stream: false,
options: {},
_clientThreadId: "parent-thread-session-id",
_cursorConversationId: parent._cursorConversationId,
_cursorIsolateConversation: true,
};
await adapter.runTurn?.(helper, { headers: new Headers() }, () => {});
expect(captured).toHaveLength(2);
expect(captured[1]?.sessionId).toBeTruthy();
expect(captured[1]?.sessionId).not.toBe(captured[0]?.sessionId);
expect(captured[1]?.sessionId).toBe(helper._cursorConversationId);
});

test("parseStream reports that the fetch path is disabled", async () => {
const adapter = createCursorAdapter(provider);
Expand Down
35 changes: 33 additions & 2 deletions tests/cursor-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../src/lib/app-owned-memory";
import {
CURSOR_EXTERNAL_ROOT_BYTE_LIMIT,
CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT,
CURSOR_EXTERNAL_ROOT_BLOB_LIMIT,
CURSOR_ROUTING_LEVEL_PARAMETER_ID,
encodeCursorRunRequest,
Expand Down Expand Up @@ -246,7 +247,7 @@ describe("Cursor blob handshake", () => {
const rootBytes = (run?.conversationState?.rootPromptMessagesJson ?? [])
.reduce((sum, id) => sum + blobData(id).byteLength, 0);

expect(run?.action?.action.case).toBe("resumeAction");
expect(run?.action?.action.case).toBe("userMessageAction");
expect(rootBytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT);
expect(JSON.stringify(roots)).toContain("[Tool Result]");
expect(JSON.stringify(roots)).toContain("truncated for Cursor external replay budget");
Expand Down Expand Up @@ -594,7 +595,7 @@ describe("Cursor blob handshake", () => {
expect(historicalUser?.content).toEqual([{ type: "text", text: "read a file" }]);
const toolResultRoot = roots.find(root => JSON.stringify(root).includes("[Tool Result]"));
expect(toolResultRoot?.role).toBe("assistant");
expect(run?.action?.action.case).toBe("resumeAction");
expect(run?.action?.action.case).toBe("userMessageAction");
expect(JSON.stringify(roots)).toContain("contents");
expect(JSON.stringify(roots)).not.toContain("hidden reasoning");
});
Expand All @@ -621,6 +622,36 @@ describe("Cursor blob handshake", () => {

expect(run?.action?.action.case).toBe("resumeAction");
});

test("drives external-model tool-result continuations as userMessageAction", () => {
// External wire models encode tool-result hops as userMessageAction; native
// models keep resumeAction. Tool results stay in the history blobs.
const bytes = encodeCursorRunRequest({
modelId: "claude-fable-5",
conversationId: "c-ext-cont",
system: ["You are helpful."],
messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }],
rawMessages: [
{ role: "user", content: "read a file", timestamp: 1 },
{
role: "assistant",
model: "cursor/claude-fable-5",
timestamp: 2,
content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }],
},
{ role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "contents", isError: false, timestamp: 3 },
],
});
const msg = fromBinary(AgentClientMessageSchema, bytes);
const run = msg.message.case === "runRequest" ? msg.message.value : undefined;

expect(run?.action?.action.case).toBe("userMessageAction");
const value = run?.action?.action.case === "userMessageAction" ? run.action.action.value : undefined;
expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT);
// Tool results are still replayed via history blobs.
const roots = decodeRootMessages(bytes) as Array<{ role?: string }>;
expect(JSON.stringify(roots)).toContain("contents");
});
});

describe("Cursor AgentRunRequest.mcp_tools channel", () => {
Expand Down
32 changes: 30 additions & 2 deletions tests/cursor-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ import { clearModelCache, getProviderDiscoveryStatus } from "../src/codex/model-
import { handleManagementAPI } from "../src/server/management-api";

async function withDiscoveryServer<T>(
handler: (stream: http2.ServerHttp2Stream) => void,
handler: (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void,
run: (baseUrl: string) => Promise<T>,
): Promise<T> {
const server = http2.createServer();
server.on("stream", handler);
server.on("stream", (stream, headers) => handler(stream, headers));
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once("error", onError);
Expand Down Expand Up @@ -443,6 +443,34 @@ describe("Cursor live transport unexpected EOF", () => {
expect(messages.at(-1)).toMatchObject({ type: "done" });
});
});
test("sends the injected session id as Connect x-session-id", async () => {
let seenSessionId: string | undefined;
await withDiscoveryServer((stream, headers) => {
const raw = headers["x-session-id"];
seenSessionId = Array.isArray(raw) ? raw[0] : raw;
stream.respond({ ":status": 200, "content-type": "application/connect+proto" });
stream.end();
}, async baseUrl => {
const transport = createLiveCursorTransport({
provider: { adapter: "cursor", baseUrl, apiKey: "test-token" },
translatorBudget: createTestTranslatorBudget(),
firstFrameTimeoutMs: 2_000,
sessionId: "cursor_from_gjc_session",
});
try {
for await (const _ of transport.run({
modelId: "composer-2",
conversationId: "cursor_header_test",
system: [],
messages: [{ role: "user", content: "hello" }],
})) { /* drain */ }
} catch { /* fixture closes immediately */ }
finally {
await transport.close?.();
}
});
expect(seenSessionId).toBe("cursor_from_gjc_session");
});

test("synthesizes done after createPlanRequestQuery text on clean Connect EOF", async () => {
const planFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, {
Expand Down
40 changes: 35 additions & 5 deletions tests/cursor-live-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe("Cursor live transport", () => {
translatorBudget: createTestTranslatorBudget(),
headers: new Headers(),
});
const sessionId = (transport as unknown as { sessionId: string }).sessionId;
const sessionId = (transport as unknown as { shellOwnerId: string }).shellOwnerId;
const fake = spawnTransportOwnedShell(sessionId);
let closed = false;
const closing = Promise.resolve(transport.close?.()).then(() => { closed = true; });
Expand All @@ -73,8 +73,8 @@ describe("Cursor live transport", () => {
translatorBudget: createTestTranslatorBudget(),
headers: new Headers(),
});
const internals = transport as unknown as { sessionId: string; cancelCursorRun(): void };
const fake = spawnTransportOwnedShell(internals.sessionId);
const internals = transport as unknown as { shellOwnerId: string; cancelCursorRun(): void };
const fake = spawnTransportOwnedShell(internals.shellOwnerId);
internals.cancelCursorRun();
const closing = Promise.resolve(transport.close?.());
await Promise.resolve();
Expand All @@ -92,19 +92,49 @@ describe("Cursor live transport", () => {
});
const internals = transport as unknown as {
sessionId: string;
shellOwnerId: string;
execContext: { sessionId?: string };
mcpManager?: { listToolHandles(): Promise<unknown[]>; dispose(): Promise<void> };
prepareMcp(): Promise<void>;
};
expect(internals.execContext.sessionId).toBe(internals.sessionId);
expect(internals.execContext.sessionId).toBe(internals.shellOwnerId);
expect(internals.execContext.sessionId).not.toBe(internals.sessionId);
internals.mcpManager = {
listToolHandles: async () => [],
dispose: async () => {},
};
await internals.prepareMcp();
expect(internals.execContext.sessionId).toBe(internals.sessionId);
expect(internals.execContext.sessionId).toBe(internals.shellOwnerId);
await transport.close?.();
});
test("honors an injected session id for Cursor Connect x-session-id", () => {
const transport = createLiveCursorTransport({
provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" },
translatorBudget: createTestTranslatorBudget(),
headers: new Headers(),
sessionId: "cursor_from_gjc_session",
});
const internals = transport as unknown as {
sessionId: string;
shellOwnerId: string;
execContext: { sessionId?: string };
};
expect(internals.sessionId).toBe("cursor_from_gjc_session");
expect(internals.execContext.sessionId).toBe(internals.shellOwnerId);
expect(internals.execContext.sessionId).not.toBe("cursor_from_gjc_session");
});
test("keeps a blank injected session id from becoming the Connect x-session-id", () => {
const transport = createLiveCursorTransport({
provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" },
translatorBudget: createTestTranslatorBudget(),
headers: new Headers(),
sessionId: " ",
});
const internals = transport as unknown as { sessionId: string };
expect(internals.sessionId.length).toBeGreaterThan(0);
expect(internals.sessionId.trim()).toBe(internals.sessionId);
expect(internals.sessionId).not.toBe(" ");
});

test("fails before network when no Cursor credential is configured", () => {
const prev = process.env.OPENCODEX_CURSOR_TEST_TOKEN;
Expand Down
Loading