From f3a7cd4a19d8ef47ac2bc150285884dfec96802e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 13:38:51 +0900 Subject: [PATCH] fix(cursor): keep provably-small bare resource_exhausted on the 429 class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live probe evidence (devlog 260822_senpi_cursor_transfer/210, 260): a plan-gated model (claude-opus-4-7-low-fast without -fast entitlement) returns the SAME bare 0-token resource_exhausted shape on a ~20-token prompt that a real payload overflow produces. #2320's overflow mapping then makes Codex compact a tiny turn — the wrong remedy for an entitlement rejection, and the retry can never succeed. Add a size prior to classifyCursorError: when the caller can prove the request was small relative to the model's context window (estimate < 50% of window), a bare RE keeps the 429-class mapping; large or unknown sizes keep today's overflow mapping, so the prior only ever removes false overflows it can prove. The adapter supplies the estimate from the outgoing request text and the static context-window table at its single error-mapping seam. Explicit quota cues and size phrases are unaffected (they classify before the prior). senpi's T01 (#1009/#1036) shares this false-overflow bug; this is a beyond-parity refinement. --- src/adapters/cursor.ts | 28 ++++++++++++++++++---- src/adapters/cursor/cursor-errors.ts | 36 ++++++++++++++++++++++++---- tests/cursor-errors.test.ts | 31 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index a6cb5cbf9d..4089e81ed3 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -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 { @@ -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 { @@ -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", @@ -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); @@ -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. @@ -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" } : {}), diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index 39f0fc5a8a..33ded8dc7c 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -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. @@ -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"; @@ -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"; } @@ -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); diff --git a/tests/cursor-errors.test.ts b/tests/cursor-errors.test.ts index 3ae5ade2cd..59a7b95399 100644 --- a/tests/cursor-errors.test.ts +++ b/tests/cursor-errors.test.ts @@ -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"); + }); +});