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
3 changes: 3 additions & 0 deletions devlog/_plan/260916_cursor_http2_toolcall/000_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ renamed the display alias. That leak is the first stacked PR. Observed
- Stop: both PRs exist with parent/child bases.
- Memory artifact: this directory.
- Terminal: DONE (PRs opened) / BLOCKED (push/template) / UNSAFE (exec default-on).
- Shipped (2026-09-16, `gh pr view` bases):
- L1 https://github.com/lidge-jun/opencodex/pull/4815 `dev` ← `cursor/l1-text-toolcall-quarantine`
- L2 https://github.com/lidge-jun/opencodex/pull/4816 `cursor/l1-text-toolcall-quarantine` ← `cursor/l2-observed-max-tokens`
- Escalation: live `api2` vs `agentn.global.api5` host cutover.

## Work-phase map
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ Host cutover, stall-resume, CLI spawn, new test-layout file.
## Verifier

NOT RUN locally. Hosted `bun test tests/providers/cursor/cursor-protobuf-events.test.ts`.

## Shipped

https://github.com/lidge-jun/opencodex/pull/4815
`dev` ← `cursor/l1-text-toolcall-quarantine` (`407bf3ce56`).
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ client-version bump.
- `cursorRequestSizeContext` feeds that window into the existing 0.5-window
overflow vs 429 prior.

## Shipped

Process-local map in `discovery.ts`; checkpoint records a positive
`maxTokens` when `wireModelId` is set from `live-transport.ts`.
`inferCursorContextWindow(modelId, observed?)` prefers explicit then
recorded then heuristic. `cursorRequestSizeContext` is unchanged except
the comment — it already calls `inferCursorContextWindow`.

https://github.com/lidge-jun/opencodex/pull/4816
`cursor/l1-text-toolcall-quarantine` ← `cursor/l2-observed-max-tokens` (`8763dee2d2`).

## Accept

- Checkpoint with `maxTokens: 32000` makes a 20-token request classify as 429
Expand Down
23 changes: 19 additions & 4 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,20 @@ function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext)
* 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 {
function cursorRequestSizeContext(request: {
modelId: string;
_cursorIdentityScope?: 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),
// Prefers this identity scope's checkpoint `maxTokens` over the id heuristic
// so a plan-gated ceiling participates in the 0.5-window overflow vs 429 prior.
contextWindow: inferCursorContextWindow(request.modelId, {
identityScope: request._cursorIdentityScope,
}),
};
}

Expand Down Expand Up @@ -171,7 +180,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
}
const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef;
const previousConversationId = _parsed._cursorConversationId;
let request = createCursorRequest(_parsed);
let request = {
...createCursorRequest(_parsed),
_cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local",
};
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,
Expand Down Expand Up @@ -413,7 +425,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
const remintConversationId = (failedConversationId: string) => {
lastTransport = undefined;
_parsed._cursorConversationId = undefined;
const next = createCursorRequest(_parsed, { forceFreshConversation: true });
const next = {
...createCursorRequest(_parsed, { forceFreshConversation: true }),
_cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local",
};
rekeyContextUsage(failedConversationId, next.conversationId);
_parsed._cursorConversationId = next.conversationId;
// Persist recovery for store:false clients that send any stable Cursor thread owner, so
Expand Down
66 changes: 65 additions & 1 deletion src/adapters/cursor/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,54 @@ const CONTEXT_272K = 272_000;
const CONTEXT_262K = 262_144;
const CONTEXT_256K = 256_000;
const CONTEXT_200K = 200_000;
export const CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES = 2_048;

export function inferCursorContextWindow(modelId: string): number {
/**
* Process-local ceilings from `ConversationTokenDetails.maxTokens` on live
* checkpoints. Each observation belongs to the Cursor identity scope that
* produced it; plan-gated accounts sharing one proxy must not overwrite each
* other's overflow prior (senpi `cursor-context-limit`).
*/
const observedCursorContextWindows = new Map<string, number>();

interface CursorContextWindowOptions {
identityScope?: string;
observed?: number;
}

function normalizeObservedWindowKey(modelId: string, identityScope?: string): string {
return `${identityScope?.trim() || "local"}\0${modelId.trim().toLowerCase()}`;
}

export function recordObservedCursorContextWindow(
modelId: string,
maxTokens: number | undefined,
options: Pick<CursorContextWindowOptions, "identityScope"> = {},
): void {
if (!modelId.trim()) return;
if (typeof maxTokens !== "number" || !Number.isFinite(maxTokens) || maxTokens <= 0) return;
const key = normalizeObservedWindowKey(modelId, options.identityScope);
observedCursorContextWindows.delete(key);
observedCursorContextWindows.set(key, Math.floor(maxTokens));
while (observedCursorContextWindows.size > CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES) {
const oldest = observedCursorContextWindows.keys().next().value;
if (oldest === undefined) break;
observedCursorContextWindows.delete(oldest);
}
}

export function observedCursorContextWindow(
modelId: string,
options: Pick<CursorContextWindowOptions, "identityScope"> = {},
): number | undefined {
return observedCursorContextWindows.get(normalizeObservedWindowKey(modelId, options.identityScope));
}

export function resetObservedCursorContextWindowsForTests(): void {
observedCursorContextWindows.clear();
}

function inferCursorContextWindowHeuristic(modelId: string): number {
const id = modelId.trim().toLowerCase();
if (id.includes("1m")) return CONTEXT_1M;
if (id.startsWith("gemini-")) return CONTEXT_1M;
Expand All @@ -40,6 +86,24 @@ export function inferCursorContextWindow(modelId: string): number {
return CURSOR_DEFAULT_CONTEXT_WINDOW;
}

/**
* Infer a conservative context window for a Cursor model id.
*
* A positive explicit observation wins, then an identity-scoped process-local
* checkpoint `maxTokens`, then the id heuristic. Cursor's `AvailableModelsResponse`
* does not currently include per-model context window metadata.
*/
export function inferCursorContextWindow(
modelId: string,
options: CursorContextWindowOptions = {},
): number {
const { observed } = options;
if (typeof observed === "number" && Number.isFinite(observed) && observed > 0) {
return Math.floor(observed);
}
return observedCursorContextWindow(modelId, options) ?? inferCursorContextWindowHeuristic(modelId);
}

function normalizeInputModalities(input: string[] | undefined): string[] {
const values = (input ?? [...CURSOR_DEFAULT_INPUT_MODALITIES])
.map(item => item.trim())
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,8 @@ class LiveCursorTransport implements CursorTransport {
syntheticStructuredEditToolNames,
translatorBudget: this.translatorBudget,
contextUsage,
wireModelId: request.modelId,
identityScope: request._cursorIdentityScope,
...(prepared.estimatedInputTokens !== undefined
? { estimatedInputTokens: prepared.estimatedInputTokens }
: {}),
Expand Down
21 changes: 20 additions & 1 deletion src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type DrainedTextToolCall,
type SuppressedTextToolCallScan,
} from "./text-toolcall";
import { recordObservedCursorContextWindow } from "./discovery";
import type { CursorServerMessage } from "./types";
import type { TranslatorBudget } from "../../lib/translator-budget";

Expand Down Expand Up @@ -193,6 +194,10 @@ export interface CursorProtobufEventState {
sawRealClientToolCall?: boolean;
/** Monotonic id suffix for tool calls promoted from text markers. */
textToolCallSeq?: number;
/** Wire model id used to record checkpoint `maxTokens` for the next turn. */
wireModelId?: string;
/** Normalized Cursor identity scope that owns the observed checkpoint ceiling. */
identityScope: string;
}


Expand Down Expand Up @@ -232,6 +237,10 @@ export function createCursorProtobufEventState(options: {
*/
estimatedInputTokens?: number;
translatorBudget?: TranslatorBudget;
/** Wire model id for recording checkpoint `maxTokens` into the process-local window map. */
wireModelId?: string;
/** Cursor request identity scope; normalized identically to request-builder continuity. */
identityScope?: string;
} = {}): CursorProtobufEventState {
return {
// Cursor provides no authoritative usage frame; token counts are heuristic estimates from
Expand Down Expand Up @@ -261,6 +270,8 @@ export function createCursorProtobufEventState(options: {
&& options.estimatedInputTokens > 0
? { estimatedInputTokens: options.estimatedInputTokens }
: {}),
...(options.wireModelId?.trim() ? { wireModelId: options.wireModelId.trim() } : {}),
identityScope: options.identityScope?.trim() || "local",
};
}

Expand Down Expand Up @@ -1255,11 +1266,19 @@ export function mapCursorProtobufServerMessage(
if (state.terminated) return [];

if (serverMessage.message.case === "conversationCheckpointUpdate") {
const usedTokens = serverMessage.message.value.tokenDetails?.usedTokens ?? 0;
const tokenDetails = serverMessage.message.value.tokenDetails;
const usedTokens = tokenDetails?.usedTokens ?? 0;
// `usedTokens` is the ABSOLUTE conversation context size, not a per-turn output delta. Track it
// separately (monotonic max) and surface it as `done.usage.totalTokens`; folding it into
// `outputTokens` (which also accumulates `tokenDelta`) double-counts in Codex. See contextTokens.
observeContextTokens(state, usedTokens);
// First checkpoints often send maxTokens=0 (senpi). Only a positive ceiling
// replaces the id heuristic for the next turn's overflow vs 429 size prior.
if (state.wireModelId) {
recordObservedCursorContextWindow(state.wireModelId, tokenDetails?.maxTokens, {
identityScope: state.identityScope,
});
}
return [];
}

Expand Down
5 changes: 5 additions & 0 deletions src/adapters/cursor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface CursorRequestedModelParameter {

export interface CursorRunRequest {
modelId: string;
/**
* Normalized Cursor identity scope carried from request parsing so checkpoint
* observations stay scope-local. Transport metadata only; never serialized on the wire.
*/
_cursorIdentityScope?: string;
/** Cursor model-picker parameters encoded through AgentRunRequest.requested_model. */
requestedModelParameters?: readonly CursorRequestedModelParameter[];
/** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */
Expand Down
17 changes: 17 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,23 @@ drops the whole textual buffer. A missing advertised-name set is fail-closed. Fi
clears any held or suppressed prefix. Coverage lives in
`tests/providers/cursor/cursor-protobuf-events.test.ts`.

## Observed checkpoint window

`conversationCheckpointUpdate.tokenDetails.maxTokens` is the account-advertised
ceiling for that wire model. A positive value is stored in a process-local map
keyed by the normalized Cursor identity scope and model id
(`src/adapters/cursor/discovery.ts`) and preferred by `inferCursorContextWindow`
only for that scope. Missing scopes normalize to the distinct `local` scope, so
they cannot inherit an authenticated account's observation. The map evicts its
oldest insertion above 2,048 entries. Zero and missing values are ignored — the
first checkpoint is often 0. The next turn's
`cursorRequestSizeContext` feeds that window into the existing 0.5-window
overflow vs 429 prior so a tiny request against a plan-gated 32k ceiling stays
on the 429 class, while a request that is large relative to the real window
classifies as overflow. Coverage lives in
`tests/providers/cursor/cursor-errors.test.ts` and
`tests/providers/cursor/cursor-protobuf-events.test.ts`.

## Overflow remint boundary

`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects, isolated helper/shadow requests and compaction remain fail-closed. Isolated requests neither consume the parent allowance nor invalidate its checkpoint. Eligible overflow checks refresh existing retention timestamps and LRU position even after the cap is exhausted, without allocating absent scopes. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy.
Expand Down
65 changes: 64 additions & 1 deletion tests/providers/cursor/cursor-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { afterEach, describe, expect, test } from "bun:test";
import {
CURSOR_AUTO_WIRE_MODEL_ID,
CURSOR_DEFAULT_CONTEXT_WINDOW,
CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES,
CURSOR_ROUTER_MODEL_IDS,
CURSOR_ROUTING_LEVELS,
CURSOR_NO_VISION_MODELS,
Expand All @@ -20,6 +21,8 @@ import {
isCursorNativeWireModel,
cursorNeedsExternalToolContinuation,
normalizeCursorModels,
recordObservedCursorContextWindow,
resetObservedCursorContextWindowsForTests,
} from "../../../src/adapters/cursor/discovery";

describe("Cursor discovery metadata", () => {
Expand Down Expand Up @@ -231,6 +234,66 @@ describe("Cursor discovery metadata", () => {
expect(cursorNeedsExternalToolContinuation("gpt-5.6-sol")).toBe(true);
});

describe("observed checkpoint maxTokens ceiling", () => {
afterEach(() => {
resetObservedCursorContextWindowsForTests();
});

test("same-model observations are isolated by normalized identity scope", () => {
recordObservedCursorContextWindow("claude-4.6-sonnet", 32_000, { identityScope: " account-a " });
recordObservedCursorContextWindow("claude-4.6-sonnet", 64_000, { identityScope: "account-b" });

expect(inferCursorContextWindow("CLAUDE-4.6-SONNET", { identityScope: "account-a" })).toBe(32_000);
expect(inferCursorContextWindow("claude-4.6-sonnet", { identityScope: " account-b " })).toBe(64_000);
});

test("an unscoped lookup does not read a scoped observation", () => {
recordObservedCursorContextWindow("claude-4.6-sonnet", 32_000, { identityScope: "account-a" });

expect(inferCursorContextWindow("claude-4.6-sonnet")).toBe(200_000);
});

test("zero, negative, missing, and non-finite maxTokens keep the heuristic", () => {
const options = { identityScope: "account-a" };
recordObservedCursorContextWindow("claude-4.6-sonnet", 0, options);
recordObservedCursorContextWindow("claude-4.6-sonnet", undefined, options);
recordObservedCursorContextWindow("claude-4.6-sonnet", Number.NaN, options);
recordObservedCursorContextWindow("claude-4.6-sonnet", -8, options);
recordObservedCursorContextWindow("", 32_000, options);
expect(inferCursorContextWindow("claude-4.6-sonnet", options)).toBe(200_000);
});

test("an explicit observed argument outranks the process-local map", () => {
const identityScope = "account-a";
recordObservedCursorContextWindow("claude-4.6-sonnet", 32_000, { identityScope });
expect(inferCursorContextWindow("claude-4.6-sonnet", { identityScope, observed: 8_000 })).toBe(8_000);
expect(inferCursorContextWindow("claude-4.6-sonnet", { identityScope, observed: 0 })).toBe(32_000);
});

test("reset clears observations from every identity scope", () => {
recordObservedCursorContextWindow("claude-4.6-sonnet", 32_000, { identityScope: "account-a" });
recordObservedCursorContextWindow("claude-4.6-sonnet", 64_000, { identityScope: "account-b" });

resetObservedCursorContextWindowsForTests();

expect(inferCursorContextWindow("claude-4.6-sonnet", { identityScope: "account-a" })).toBe(200_000);
expect(inferCursorContextWindow("claude-4.6-sonnet", { identityScope: "account-b" })).toBe(200_000);
});

test("evicts the oldest observation after the bounded capacity", () => {
recordObservedCursorContextWindow("oldest-model", 32_000, { identityScope: "account-oldest" });
for (let index = 1; index <= CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES; index++) {
recordObservedCursorContextWindow(`model-${index}`, 32_000 + index, { identityScope: `account-${index}` });
}

expect(inferCursorContextWindow("oldest-model", { identityScope: "account-oldest" }))
.toBe(CURSOR_DEFAULT_CONTEXT_WINDOW);
expect(inferCursorContextWindow(`model-${CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES}`, {
identityScope: `account-${CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES}`,
})).toBe(32_000 + CURSOR_OBSERVED_CONTEXT_WINDOW_MAX_ENTRIES);
});
});

test("normalizes Cursor checkpoint model affinity across prefix and effort", () => {
expect(cursorCheckpointModelAffinityId("cursor/grok-4.6")).toBe(
cursorCheckpointModelAffinityId("cursor-grok-4.6-low"),
Expand Down
Loading
Loading