Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
72 changes: 51 additions & 21 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { AdapterEvent, OcxProviderConfig } from "../types";
import type { ProviderAdapter } from "./base";
import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
import { isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor/cursor-errors";
import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors";
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";
Expand Down Expand Up @@ -70,26 +71,55 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
const makeTransport = deps.createTransport ?? createLiveCursorTransport;
const kv = deps.kv ?? createCursorKvStore();
_parsed._cursorConversationId ??= generatedCursorConversationId();
const request = createCursorRequest(_parsed);
await runCursorTurnWithRetry(
makeTransport,
{ provider, headers: incoming.headers, requestDeclaresFullAccess: cursorRequestDeclaresFullAccess(request) },
request,
incoming.abortSignal,
(message, activeTransport) => {
if (incoming.abortSignal?.aborted) {
emit({ type: "error", message: "Cursor turn was aborted." });
return;
}
const events = mapCursorServerMessage(message, {
kv,
writeClient: clientMessage => {
void activeTransport.writeClient(clientMessage);
},
});
for (const event of events) emit(event);
},
);
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).
_parsed._cursorConversationId = request.conversationId;

const runOnce = async (activeRequest: ReturnType<typeof createCursorRequest>) => {
await runCursorTurnWithRetry(
makeTransport,
{
provider,
headers: incoming.headers,
requestDeclaresFullAccess: cursorRequestDeclaresFullAccess(activeRequest),
},
activeRequest,
incoming.abortSignal,
(message, activeTransport) => {
if (incoming.abortSignal?.aborted) {
emit({ type: "error", message: "Cursor turn was aborted." });
return;
}
const events = mapCursorServerMessage(message, {
kv,
writeClient: clientMessage => {
void activeTransport.writeClient(clientMessage);
},
});
for (const event of events) emit(event);
},
);
};

try {
await runOnce(request);
} catch (err) {
// One-shot fallback: external Cursor models can reject resume/continuation on a
// stale server-side conversation with Connect invalid_argument after stepCompleted.
// Replay once with a fresh conversation id and full history.
if (
!isCursorInvalidArgumentError(err)
|| !isCursorExternalWireModel(request.modelId)
|| incoming.abortSignal?.aborted
Comment thread
Wibias marked this conversation as resolved.
) {
throw err;
}
_parsed._cursorConversationId = undefined;
request = createCursorRequest(_parsed, { forceFreshConversation: true });
_parsed._cursorConversationId = request.conversationId;
await runOnce(request);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (err) {
if (isCursorBenignCancelError(err)) return;
const partialUsage = (err as { partialUsage?: import("../types").OcxUsage }).partialUsage;
Expand Down
11 changes: 11 additions & 0 deletions src/adapters/cursor/cursor-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export function isCursorBenignCancelError(value: unknown): boolean {
return false;
}

/**
* True when Cursor Connect rejected the turn with invalid_argument.
* Seen after stepCompleted on brittle external-model continuations.
*/
export function isCursorInvalidArgumentError(value: unknown): boolean {
const code = errorCode(value).toLowerCase();
if (code === "invalid_argument") return true;
const message = errorMessage(value).toLowerCase();
return message.includes("invalid_argument");
}

/**
* Classify a Cursor transport/Connect/gRPC error message into an actionable category.
* The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords,
Expand Down
25 changes: 25 additions & 0 deletions src/adapters/cursor/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,31 @@ export function cursorCodexToWireModelId(modelId: string): string {
return cursorWireModelSelection(modelId).modelId;
}

/**
* Cursor-native wire models keep server-side conversation state reliably.
* External models (gpt/claude/gemini/grok families and similar) are more brittle on resumeAction.
*/
export function isCursorNativeWireModel(modelId: string): boolean {
const wire = cursorCodexToWireModelId(modelId).trim().toLowerCase();
const bare = stripCursorEffortSuffix(wire);
if (bare === CURSOR_AUTO_WIRE_MODEL_ID || bare === CURSOR_AUTO_MODEL_ID) return true;
return bare.startsWith("composer-");
}

/** Inverse of {@link isCursorNativeWireModel}. */
export function isCursorExternalWireModel(modelId: string): boolean {
return !isCursorNativeWireModel(modelId);
}

function stripCursorEffortSuffix(wireModelId: string): string {
const suffixes = [...CANONICAL_EFFORT_SUFFIXES].sort((a, b) => b.length - a.length);
for (const suffix of suffixes) {
const marker = `-${suffix}`;
if (wireModelId.endsWith(marker)) return wireModelId.slice(0, -marker.length);
}
return wireModelId;
}

export function isCursorRouterModelId(modelId: string): boolean {
return (CURSOR_ROUTER_MODEL_IDS as readonly string[]).includes(modelId);
}
Expand Down
12 changes: 11 additions & 1 deletion src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ValueSchema } from "@bufbuild/protobuf/wkt";
import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types";
import { namespacedToolName } from "../../types";
import type { CursorRunRequest } from "./types";
import { isCursorExternalWireModel } from "./discovery";
import { debugProviderDiagnostic } from "../../lib/debug";
import { storeCursorBlob } from "./native-exec";
import {
AgentClientMessageSchema,
Expand Down Expand Up @@ -326,8 +328,9 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
// would pollute the model input and double-deliver the result. Use ResumeAction so Cursor picks up
// from the history we provided.
const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult";
const actionCase = !lastRawIsToolResult && text.trim().length > 0 ? "userMessageAction" : "resumeAction";
const action = create(ConversationActionSchema, {
action: !lastRawIsToolResult && text.trim().length > 0
action: actionCase === "userMessageAction"
? {
case: "userMessageAction",
value: create(UserMessageActionSchema, {
Expand All @@ -345,6 +348,13 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
}),
},
});
debugProviderDiagnostic("cursor", "run-request", {
wireModel: request.modelId,
action: actionCase,
conversationId: request.conversationId,
turnType: lastRawIsToolResult ? "tool-continuation" : "initial",
externalModel: isCursorExternalWireModel(request.modelId),
});

const runRequest = create(AgentRunRequestSchema, {
conversationId: request.conversationId,
Expand Down
23 changes: 20 additions & 3 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} from "../../types";
import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
import type { CursorRequestMessage, CursorRunRequest } from "./types";
import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery";
import { cursorWireModelSelection, isCursorExternalWireModel, type CursorRoutingLevel } from "./discovery";
import { cursorEffortSuffix } from "./effort-map";
import {
cursorMcpToolEncodedSize,
Expand Down Expand Up @@ -159,7 +159,15 @@ export function generatedCursorConversationId(): string {
return `cursor_${crypto.randomUUID().replace(/-/g, "")}`;
}

export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest {
export interface CreateCursorRequestOptions {
/** Force a brand-new Cursor conversation id even when remembered state exists. */
forceFreshConversation?: boolean;
}

export function createCursorRequest(
parsed: OcxParsedRequest,
options: CreateCursorRequestOptions = {},
): CursorRunRequest {
const messages = parsed.context.messages
.map(requestMessage)
.filter((message): message is CursorRequestMessage => !!message && message.content.length > 0);
Expand All @@ -168,14 +176,23 @@ export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest
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: parsed._cursorConversationId ?? generatedCursorConversationId(),
conversationId: forceFreshConversation
? generatedCursorConversationId()
: (parsed._cursorConversationId ?? generatedCursorConversationId()),
Comment thread
Wibias marked this conversation as resolved.
system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])],
messages,
rawMessages: parsed.context.messages,
Expand Down
62 changes: 62 additions & 0 deletions tests/cursor-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,66 @@ describe("Cursor adapter live transport", () => {
expect(cursorExecDeniedMessage("shellArgs")).toContain("shellArgs");
expect(cursorExecDeniedMessage("shellArgs")).toContain("legacy mock transport cannot execute");
});

test("retries external-model invalid_argument once with a fresh conversation id", async () => {
const seen: string[] = [];
let attempts = 0;
const adapter = createCursorAdapter({
...provider,
apiKey: "cursor-token",
}, {
createTransport: () => ({
async *run(request) {
attempts += 1;
seen.push(request.conversationId);
if (attempts === 1) {
throw Object.assign(
new Error("Cursor invalid request: Cursor Connect error invalid_argument: Error"),
{ code: "invalid_argument" },
);
}
yield { type: "done" } satisfies CursorServerMessage;
},
writeClient() {},
}),
});

const events: AdapterEvent[] = [];
const body: OcxParsedRequest = {
modelId: "cursor/gpt-5.6-sol",
context: {
messages: [
{ role: "user", content: "read a file", timestamp: 1 },
{
role: "assistant",
model: "cursor/gpt-5.6-sol",
timestamp: 2,
content: [{ type: "toolCall", id: "call_1", name: "read_file", namespace: "mcp__fs", arguments: { path: "a.txt" } }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read_file",
toolNamespace: "mcp__fs",
content: "FILE CONTENTS HERE",
isError: false,
timestamp: 3,
},
],
},
stream: false,
options: { reasoning: "xhigh" },
_cursorConversationId: "cursor_corrupt",
};

await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event));

expect(attempts).toBe(2);
expect(seen).toHaveLength(2);
expect(seen[0]).not.toBe("cursor_corrupt");
expect(seen[1]).not.toBe(seen[0]);
expect(body._cursorConversationId).toBe(seen[1]);
expect(events.filter(event => event.type === "error")).toHaveLength(0);
});
});

13 changes: 13 additions & 0 deletions tests/cursor-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
cursorModelReasoningEfforts,
cursorWireModelSelection,
inferCursorContextWindow,
isCursorExternalWireModel,
isCursorNativeWireModel,
normalizeCursorModels,
} from "../src/adapters/cursor/discovery";

Expand Down Expand Up @@ -145,4 +147,15 @@ describe("Cursor discovery metadata", () => {
expect(efforts["unknown-reasoning-model"]).toEqual([]);
expect(efforts["composer-2.5"]).toEqual([]);
});

test("classifies native vs external Cursor wire models", () => {
expect(isCursorNativeWireModel("default")).toBe(true);
expect(isCursorNativeWireModel("auto")).toBe(true);
expect(isCursorNativeWireModel("composer-2.5")).toBe(true);
expect(isCursorNativeWireModel("composer-2.5-fast")).toBe(true);
expect(isCursorExternalWireModel("gpt-5.6-sol")).toBe(true);
expect(isCursorExternalWireModel("gpt-5.6-sol-xhigh")).toBe(true);
expect(isCursorExternalWireModel("claude-4.6-sonnet-high")).toBe(true);
expect(isCursorExternalWireModel("cursor/gpt-5.6-sol")).toBe(true);
});
});
10 changes: 10 additions & 0 deletions tests/cursor-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
classifyCursorError,
isCursorBenignCancelError,
isCursorInvalidArgumentError,
safeCursorErrorMessage,
} from "../src/adapters/cursor/cursor-errors";

Expand Down Expand Up @@ -81,3 +82,12 @@ describe("safeCursorErrorMessage", () => {
expect(msg).not.toContain("rate limit");
});
});


describe("isCursorInvalidArgumentError", () => {
test("matches Connect invalid_argument code and message", () => {
expect(isCursorInvalidArgumentError({ code: "invalid_argument", message: "Cursor invalid request" })).toBe(true);
expect(isCursorInvalidArgumentError(new Error("Cursor invalid request: Cursor Connect error invalid_argument: Error"))).toBe(true);
expect(isCursorInvalidArgumentError(new Error("Cursor connection failed"))).toBe(false);
});
});
Loading
Loading