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
28 changes: 23 additions & 5 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { AdapterEvent, OcxProviderConfig } from "../types";
import type { ProviderAdapter } from "./base";
import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors";
import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery";
import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
import { mapCursorServerMessage } from "./cursor/message-mapper";
import {
Expand All @@ -25,6 +25,7 @@ import {
invalidateCursorCheckpoint,
} from "./cursor/checkpoint-store";
import { debugProviderDiagnostic } from "../lib/debug";
import { estimateTokens } from "../lib/token-estimate";
import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
import { runCursorTurnWithRetry } from "./cursor/transport-retry";
import {
Expand Down Expand Up @@ -53,16 +54,29 @@ export interface CursorAdapterDeps {
rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void;
}

function safeCursorTransportError(err: unknown): string {
function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string {
if (err instanceof CursorTransportDisabledError) return CURSOR_TRANSPORT_DISABLED_MESSAGE;
if (err instanceof CursorMissingCredentialError) {
return "Cursor live transport is enabled, but no Cursor access token is configured. Set provider.apiKey or OPENCODEX_CURSOR_TEST_TOKEN.";
}
const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined;
if (message) return safeCursorErrorMessage(message);
if (message) return safeCursorErrorMessage(message, sizeContext);
return "Cursor upstream error: transport failed before completion.";
}

/**
* Size prior for bare resource_exhausted classification (devlog 260): a rough input
* estimate over the outgoing text vs the model's context window. Only used to keep
* SMALL requests on the 429 class — unknown/large stays on the overflow mapping.
*/
function cursorRequestSizeContext(request: { modelId: string; system: string[]; messages: { content: string }[] }): CursorSizeContext {
const text = [...request.system, ...request.messages.map(message => message.content)].join("\n");
return {
estimatedInputTokens: estimateTokens(text, request.modelId),
contextWindow: inferCursorContextWindow(request.modelId),
};
}

export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAdapterDeps = {}): ProviderAdapter {
return {
name: "cursor",
Expand All @@ -88,6 +102,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
emit({ type: "error", message: "Cursor turn was aborted before start." });
return;
}
// Captured after createCursorRequest so the catch block can apply the bare-RE
// size prior (devlog 260) even though `request` is scoped inside the try.
let requestSizeContext: CursorSizeContext | undefined;
try {
const makeTransport = deps.createTransport ?? createLiveCursorTransport;
const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget);
Expand All @@ -110,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef;
const previousConversationId = _parsed._cursorConversationId;
let request = createCursorRequest(_parsed);
requestSizeContext = cursorRequestSizeContext(request);
// The builder may derive a stable provider id from the client thread when Responses state
// is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn,
// and isolated helper/compaction turns must never inherit or donate the parent's usage state.
Expand Down Expand Up @@ -292,7 +310,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
type: "error",
message: isTranslatorBudgetExceededError(err)
? "upstream translation buffer exceeded the safe limit"
: safeCursorTransportError(err),
: safeCursorTransportError(err, requestSizeContext),
...(isTranslatorBudgetExceededError(err)
? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" }
: {}),
Expand Down
36 changes: 32 additions & 4 deletions src/adapters/cursor/cursor-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,30 @@ const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit
*/
const BARE_RE_TAILS = new Set(["error", "", "resource_exhausted", "resource exhausted"]);

/**
* Size prior for bare resource_exhausted classification (devlog 260, live probe 210):
* a plan-gated model returns the SAME bare RE shape on a ~20-token prompt that a real
* payload overflow produces, so the message alone cannot separate "compact and retry"
* from "this account cannot use this model". When the caller can supply how large the
* request actually was relative to the model's window, a small request keeps the
* 429-class mapping; only a plausibly-large one classifies as overflow. Unknown
* sizes keep today's overflow mapping so the prior only ever REMOVES false overflows
* it can prove.
*/
export interface CursorSizeContext {
estimatedInputTokens?: number;
contextWindow?: number;
}

const OVERFLOW_MIN_FRACTION = 0.5;

function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean {
if (!context) return true;
const { estimatedInputTokens, contextWindow } = context;
if (estimatedInputTokens === undefined || contextWindow === undefined || contextWindow <= 0) return true;
return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow;
}

export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean {
if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false;
// Any explicit quota/rate cue wins: this is a real 429.
Expand Down Expand Up @@ -172,7 +196,7 @@ export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean {
* The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords,
* so bridge-level error mapping produces the right Codex error type (rate_limit, auth, etc.).
*/
export function classifyCursorError(message: string): string {
export function classifyCursorError(message: string, sizeContext?: CursorSizeContext): string {
const lower = message.toLowerCase();

if (isCursorBenignCancelError(message)) return "Cursor stream suspended";
Expand All @@ -190,7 +214,11 @@ export function classifyCursorError(message: string): string {
// A bare resource_exhausted with no quota cue and no size phrase is payload
// overflow, not rate limiting. Classifying it as 429 makes Codex back off on a
// failure that only compaction can fix (senpi #1009 / #1036; research unit T01).
if (isCursorZeroTokenResourceExhausted(lower)) return "Cursor context limit exceeded";
// Refinement (devlog 260): plan-gated models emit the same bare shape on tiny
// requests — when the caller proves the request was small, keep the 429 class.
if (isCursorZeroTokenResourceExhausted(lower)) {
return bareReLooksLikeOverflow(sizeContext) ? "Cursor context limit exceeded" : "Cursor rate limit exceeded";
}
return "Cursor rate limit exceeded";
}

Expand Down Expand Up @@ -251,8 +279,8 @@ export function classifyCursorError(message: string): string {
* Produce a user-facing, secret-safe Cursor error message with an actionable category prefix.
* Mirrors `safeKiroErrorMessage` / `safeKiroHttpErrorMessage` in kiro-errors.ts.
*/
export function safeCursorErrorMessage(rawMessage: string): string {
const prefix = classifyCursorError(rawMessage);
export function safeCursorErrorMessage(rawMessage: string, sizeContext?: CursorSizeContext): string {
const prefix = classifyCursorError(rawMessage, sizeContext);
const detail = sanitize(rawMessage)
.replace(/resource[_ ]exhausted/gi, "resource limit exceeded")
.slice(0, 500);
Expand Down
31 changes: 31 additions & 0 deletions tests/cursor-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,34 @@ describe("isCursorInvalidArgumentError", () => {
expect(isCursorInvalidArgumentError(new Error("Cursor connection failed"))).toBe(false);
});
});

describe("bare resource_exhausted size prior (devlog 260)", () => {
const BARE = "Cursor Connect error resource_exhausted: Error";

test("a provably small request keeps the 429 class (plan-gated model, live probe 210)", () => {
expect(classifyCursorError(BARE, { estimatedInputTokens: 20, contextWindow: 200_000 }))
.toBe("Cursor rate limit exceeded");
});

test("a plausibly large request still classifies as context overflow", () => {
expect(classifyCursorError(BARE, { estimatedInputTokens: 150_000, contextWindow: 200_000 }))
.toBe("Cursor context limit exceeded");
});

test("unknown estimate or window keeps today's overflow mapping (prior only removes provable false overflows)", () => {
expect(classifyCursorError(BARE)).toBe("Cursor context limit exceeded");
expect(classifyCursorError(BARE, {})).toBe("Cursor context limit exceeded");
expect(classifyCursorError(BARE, { estimatedInputTokens: 20 })).toBe("Cursor context limit exceeded");
expect(classifyCursorError(BARE, { contextWindow: 200_000 })).toBe("Cursor context limit exceeded");
});

test("explicit quota cues stay 429 regardless of size context", () => {
expect(classifyCursorError("resource_exhausted: quota exhausted", { estimatedInputTokens: 150_000, contextWindow: 200_000 }))
.toBe("Cursor rate limit exceeded");
});

test("explicit size phrases stay resource-limit regardless of size context", () => {
expect(classifyCursorError("resource_exhausted: request body exceeds maximum allowed size", { estimatedInputTokens: 20, contextWindow: 200_000 }))
.toBe("Cursor resource limit exceeded");
});
});
Loading