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
91 changes: 69 additions & 22 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
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";
import { createLiveCursorTransport, CursorMissingCredentialError } from "./cursor/live-transport";
import {
createLiveCursorTransport,
CursorMissingCredentialError,
rekeyCursorContextUsage,
} from "./cursor/live-transport";
import { runCursorTurnWithRetry } from "./cursor/transport-retry";
import {
createDisabledCursorTransport,
Expand Down Expand Up @@ -70,26 +75,68 @@ 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);
},
);
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) {
rekeyCursorContextUsage(previousConversationId, request.conversationId);
}
_parsed._cursorConversationId = request.conversationId;
let emittedOutput = false;
const lastRawIsToolResult = _parsed.context.messages.at(-1)?.role === "toolResult";

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) {
if (event.type !== "heartbeat") emittedOutput = true;
emit(event);
}
},
);
};

try {
await runOnce(request);
} catch (err) {
// One-shot fallback: only for external-model tool-result continuations that fail
// with Connect invalid_argument before any non-heartbeat output was forwarded.
// Replaying after text/tool events would duplicate output.
if (
!isCursorInvalidArgumentError(err)
|| !isCursorExternalWireModel(request.modelId)
|| !lastRawIsToolResult
|| emittedOutput
|| incoming.abortSignal?.aborted
Comment thread
Wibias marked this conversation as resolved.
) {
throw err;
}
const failedConversationId = request.conversationId;
_parsed._cursorConversationId = undefined;
request = createCursorRequest(_parsed, { forceFreshConversation: true });
rekeyCursorContextUsage(failedConversationId, request.conversationId);
_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
5 changes: 5 additions & 0 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ const GENERIC_TOOL_COUNT_MAX_FINALIZE_GRACE_MS = 1_800;
const GENERIC_TOOL_COUNT_PER_TOOL_GRACE_MS = 125;
const cursorContextUsageTracker = createCursorContextUsageTracker();

/** Carry context-usage totals across conversation-id rotation for external-model replay. */
export function rekeyCursorContextUsage(fromConversationId: string, toConversationId: string): void {
cursorContextUsageTracker.rekey(fromConversationId, toConversationId);
}

export class CursorMissingCredentialError extends Error {
readonly code = "cursor_missing_credential";

Expand Down
14 changes: 14 additions & 0 deletions src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface CursorContextUsageTracker {
controlsForConversation(conversationId: string, options?: { clearPrior?: boolean; storeCheckpoints?: boolean }): CursorContextUsageControls;
get(conversationId: string): number | undefined;
record(conversationId: string, tokens: number): void;
/** Copy numeric carry-forward totals when a conversation id is rotated for replay. */
rekey(fromConversationId: string, toConversationId: string): void;
clear(conversationId: string): void;
clearAll(): void;
}
Expand Down Expand Up @@ -91,6 +93,18 @@ export function createCursorContextUsageTracker(options: { maxEntries?: number;
},
get,
record,
rekey(fromConversationId, toConversationId) {
if (!fromConversationId || !toConversationId || fromConversationId === toConversationId) return;
prune();
const from = entries.get(fromConversationId);
if (!from) return;
const to = entries.get(toConversationId);
const tokens = Math.max(from.tokens, to?.tokens ?? 0);
entries.delete(fromConversationId);
entries.delete(toConversationId);
entries.set(toConversationId, { tokens, updatedAt: now() });
prune();
},
clear(conversationId) {
entries.delete(conversationId);
},
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
Loading
Loading