From 29cb2dcd0fc8bc1a14c1c3bc1d490821ee3d19d0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:31:56 +0200 Subject: [PATCH 1/2] fix(cursor): replay external model tool continuations with fresh conversation ids External Cursor models like gpt-5.6-sol can reject resumeAction on a stale server-side conversation with Connect invalid_argument after stepCompleted. Mint a fresh conversation id for external tool-result continuations, keep native composer resume unchanged, and retry once with full history replay. --- src/adapters/cursor.ts | 72 +++++++++++++++++------- src/adapters/cursor/cursor-errors.ts | 11 ++++ src/adapters/cursor/discovery.ts | 25 +++++++++ src/adapters/cursor/protobuf-request.ts | 12 +++- src/adapters/cursor/request-builder.ts | 23 +++++++- tests/cursor-adapter.test.ts | 62 ++++++++++++++++++++ tests/cursor-discovery.test.ts | 13 +++++ tests/cursor-errors.test.ts | 10 ++++ tests/cursor-request-builder.test.ts | 75 +++++++++++++++++++++++++ 9 files changed, 278 insertions(+), 25 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 875cf4bdc1c..56177be8d62 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -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"; @@ -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) => { + 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 + ) { + throw err; + } + _parsed._cursorConversationId = undefined; + request = createCursorRequest(_parsed, { forceFreshConversation: true }); + _parsed._cursorConversationId = request.conversationId; + await runOnce(request); + } } catch (err) { if (isCursorBenignCancelError(err)) return; const partialUsage = (err as { partialUsage?: import("../types").OcxUsage }).partialUsage; diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index a3dff777244..3290b7cffdb 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -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, diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 82fe654b5db..60006844855 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -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); } diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 0304731d873..bd22d077571 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -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, @@ -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, { @@ -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, diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 1a115a1b075..3a548bd3bbd 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -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, @@ -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); @@ -168,6 +176,13 @@ 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 } : {}), @@ -175,7 +190,9 @@ export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest // 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()), system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])], messages, rawMessages: parsed.context.messages, diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index b64b902371b..9269f8e882f 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -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); + }); }); + diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index de896f225e5..9c6a28f9236 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -14,6 +14,8 @@ import { cursorModelReasoningEfforts, cursorWireModelSelection, inferCursorContextWindow, + isCursorExternalWireModel, + isCursorNativeWireModel, normalizeCursorModels, } from "../src/adapters/cursor/discovery"; @@ -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); + }); }); diff --git a/tests/cursor-errors.test.ts b/tests/cursor-errors.test.ts index 3a7ea847195..48ca7a6ce5d 100644 --- a/tests/cursor-errors.test.ts +++ b/tests/cursor-errors.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { classifyCursorError, isCursorBenignCancelError, + isCursorInvalidArgumentError, safeCursorErrorMessage, } from "../src/adapters/cursor/cursor-errors"; @@ -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); + }); +}); diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index c71ed01e9f2..b08eb52ea15 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -223,4 +223,79 @@ describe("Cursor request builder", () => { expect(withoutSearch.system.join("\n")).toContain("unavailable this turn"); expect(withoutSearch.system.join("\n")).not.toContain("Use tool_search"); }); + + + test("external Cursor tool-result continuation forces a fresh conversation id", () => { + const request = createCursorRequest({ + 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_old_external", + }); + + expect(request.modelId).toBe("gpt-5.6-sol-xhigh"); + expect(request.conversationId).not.toBe("cursor_old_external"); + expect(request.conversationId.startsWith("cursor_")).toBe(true); + }); + + test("native Cursor tool-result continuation keeps the remembered conversation id", () => { + const request = createCursorRequest({ + modelId: "cursor/composer-2.5", + context: { + messages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/composer-2.5", + 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: {}, + _cursorConversationId: "cursor_native_stable", + }); + + expect(request.conversationId).toBe("cursor_native_stable"); + }); + + test("forceFreshConversation always mints a new conversation id", () => { + const request = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_force_me", + }, { forceFreshConversation: true }); + + expect(request.conversationId).not.toBe("cursor_force_me"); + expect(request.conversationId.startsWith("cursor_")).toBe(true); + }); }); From 9cf0abd4b0309d59697640d316dadedf73fe2e6c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:10:56 +0200 Subject: [PATCH 2/2] fix(cursor): gate external continuation replay and rekey usage carry-forward Address PR #318 review feedback: - retry invalid_argument only for external tool-result continuations with no committed output - track emitted non-heartbeat events to avoid duplicate text/tool replay - rekey Cursor context-usage carry-forward when conversation ids rotate - add regression coverage for both behaviors --- src/adapters/cursor.ts | 27 ++++++++++--- src/adapters/cursor/live-transport.ts | 5 +++ src/adapters/cursor/protobuf-events.ts | 14 +++++++ tests/cursor-adapter.test.ts | 53 ++++++++++++++++++++++++++ tests/cursor-protobuf-events.test.ts | 10 +++++ 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 56177be8d62..15a59e089bd 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -6,7 +6,11 @@ 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, @@ -71,10 +75,16 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const makeTransport = deps.createTransport ?? createLiveCursorTransport; const kv = deps.kv ?? createCursorKvStore(); _parsed._cursorConversationId ??= generatedCursorConversationId(); + 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) => { await runCursorTurnWithRetry( @@ -97,7 +107,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda void activeTransport.writeClient(clientMessage); }, }); - for (const event of events) emit(event); + for (const event of events) { + if (event.type !== "heartbeat") emittedOutput = true; + emit(event); + } }, ); }; @@ -105,18 +118,22 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda 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. + // 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 ) { 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); } diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index d13d5963e4d..bad4171af38 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -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"; diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 05433dbada0..bb2fdc06ff9 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -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; } @@ -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); }, diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index 9269f8e882f..2452c1da316 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -203,3 +203,56 @@ describe("Cursor adapter live transport", () => { }); }); + test("does not replay invalid_argument after non-heartbeat output was already emitted", async () => { + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + yield { type: "text", text: "partial output" } satisfies CursorServerMessage; + throw Object.assign( + new Error("Cursor invalid request: Cursor Connect error invalid_argument: Error"), + { code: "invalid_argument" }, + ); + }, + 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(1); + expect(events.some(event => event.type === "text_delta")).toBe(true); + expect(events.some(event => event.type === "error")).toBe(true); + }); diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 5eb0d69a86d..be2894ca810 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -589,6 +589,16 @@ describe("Cursor protobuf tool-call events", () => { ]); expect(tracker.get("cursor_conv_1")).toBeUndefined(); }); + + test("rekey copies carry-forward totals onto a rotated conversation id", () => { + const tracker = createCursorContextUsageTracker(); + tracker.record("cursor_old", 12_500); + tracker.rekey("cursor_old", "cursor_new"); + + expect(tracker.get("cursor_old")).toBeUndefined(); + expect(tracker.get("cursor_new")).toBe(12_500); + expect(tracker.controlsForConversation("cursor_new").carryForwardTokens).toBe(12_500); + }); }); describe("Cursor MCP display-name alias", () => {