Files
hapi/shared/src/cursorCliSku.ts
T
ca1fd80afd fix(cursor): pin Auto to CLI --model auto instead of account default (#1819)
* fix(cursor): pin Auto to CLI --model auto instead of account default

Selecting Auto was treated as unset/ACP default[] after the Default→Auto rename. Persist and spawn `auto` so the picker matches `agent --list-models`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): keep live ACP model until Auto actually applies

Pin session.model to auto for the next `--model auto` spawn, but do not claim the live backend switched when ACP only advertises default[].

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): reject live Auto unless ACP auto or spawn confirmed it

Do not persist Auto when the running catalog has no auto option. Clear the spawn-auto flag after a concrete model apply so Auto → concrete → Auto cannot fake a live switch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): keep ACP wire ids spawn-safe and apply effort

Cursor's parameterized picker (`clientCapabilities._meta.parameterizedModelPicker`)
advertises bare model bases plus separate `fast` / `thought_level` config options.
`buildCursorModelsSnapshotFromAcp` synthesized every base as
`${base}[fast=${fast}]`, and Cursor rejects any bracket id whose parameter set is
not that model's complete set, so 74 of the 76 published picker ids failed at ACP
`session/new` with `Cannot use this model` (#1818).

- Stop synthesizing wire ids: keep the advertised values verbatim (bare bases are
  accepted by `--model`, defaults applied). Parameter sets differ per model, so a
  synthesized id cannot be validated from the options alone.
- Flag such catalogs as `parameterized` so variant CLI skus still attach to their
  base rows — the ACP apply path expresses them through config options.
- Apply effort in `applyParameterizedCursorModel` (ids `effort`, `reasoning`,
  `reasoning_effort`), applied against the config options Cursor re-advertises for
  the newly selected model, and skipped on models without that axis.
- Match CLI sku bases across Cursor's legacy `cursor-` family prefix
  (`cursor-grok-4.6-high` → ACP base `grok-4.6`) so those variant rows are not
  dropped.
- Version the shared on-disk catalog cache (v2) so runners that already cached the
  rejected synthesized ids re-probe instead of serving them.

Verified with live `cursor-agent 2026.09.10-fd3934a` ACP runs: the catalog no
longer contains bracket ids, every advertised base and sampled variant sku spawns,
and in-session `claude-opus-4-8-low` / `cursor-grok-4.6-low` set the matching
`effort`/`reasoning` option.

* test(cursor): cover aliased effort ids and thinking coexistence

Covers the branches the HAPI bot review called out for #1818: the
`xhigh` → `extra-high` alias on `gpt-5.5` (`reasoning`), the
`reasoning_effort` id used by `gemini-3.8-flash`, and switching effort on a
model that also advertises Claude's separate `thought_level` `thinking`
toggle.

* test(cursor): cover legacy sku base and post-switch effort option

Adds the two apply-path cases the HAPI bot review called out for #1818:
resolving `cursor-grok-4.6-high` onto an advertised `grok-4.6` base, and
reading the effort option Cursor advertises for the newly selected model
(`reasoning` for gpt-5.5 → `effort` for claude-opus-4-8) instead of the
previous model's options.

* test(cursor): create the cache dir in the migration test

The version-envelope test wrote $HAPI_HOME/cache/cursor-models.json without
creating the directory, so running it alone (or filtering with -t) failed with
ENOENT before the assertion; it only passed because sibling tests in the same
file had already created the path. Reported in the HAPI bot review of #1822.

Verified: `vitest run ... -t 'ignores pre-versioned'` → 1 passed, whole file → 4 passed,
and every other changed test file passes when run standalone.

* Clarify Cursor Auto live-switch limitation in session pickers

* Reconcile partial Cursor model changes before acknowledging Auto

* Serialize Cursor model changes across clients with the existing async lock

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 08:12:16 +08:00

537 lines
17 KiB
TypeScript

/** Renamed ACP wire bases → live Cursor CLI sku family (e.g. grok-4.5 → cursor-grok-4.5). */
const CURSOR_LEGACY_MODEL_BASE_ALIASES: Readonly<Record<string, string>> = {
'grok-4.5': 'cursor-grok-4.5',
};
export function resolveCursorLegacyModelBase(baseId: string): string {
const trimmed = baseId.trim();
return CURSOR_LEGACY_MODEL_BASE_ALIASES[trimmed] ?? trimmed;
}
/** CLI `agent --list-models` id for Cursor Auto (`auto - Auto`). */
export const CURSOR_AUTO_MODEL_ID = 'auto';
/** ACP parameterized wire ids use bracket params; CLI `agent --list-models` slugs do not. */
export function isCursorAcpWireModelId(modelId: string): boolean {
const trimmed = modelId.trim();
return trimmed === 'default[]' || trimmed.includes('[');
}
/** True for CLI Auto and the legacy default aliases that must not stay as a Default option. */
export function isCursorAutoModelId(modelId: string | null | undefined): boolean {
const normalized = modelId?.trim().toLowerCase();
return normalized === 'auto' || normalized === 'default' || normalized === 'default[]';
}
/** Spawn `--model` value: pin Auto as `auto`. Empty/unset omits `--model`. */
export function cursorSpawnModelId(model: string | null | undefined): string | null {
const trimmed = model?.trim();
if (!trimmed) {
return null;
}
return isCursorAutoModelId(trimmed) ? CURSOR_AUTO_MODEL_ID : trimmed;
}
export function cursorModelBaseId(modelId: string): string {
const trimmed = modelId.trim();
const bracket = trimmed.indexOf('[');
return bracket === -1 ? trimmed : trimmed.slice(0, bracket);
}
function stripCursorFamilyPrefix(base: string): string {
return base.startsWith('cursor-') ? base.slice('cursor-'.length) : base;
}
/**
* Compare an ACP wire base with a CLI sku base. Cursor's CLI keeps a legacy `cursor-`
* family prefix for some models (`cursor-grok-4.6-high` vs ACP base `grok-4.6`), so a
* plain equality check silently drops those variant rows.
*/
export function cursorModelBaseMatches(a: string, b: string): boolean {
const left = a.trim();
const right = b.trim();
if (left === right) {
return true;
}
return stripCursorFamilyPrefix(left) === stripCursorFamilyPrefix(right);
}
/** Longest-first suffixes from Cursor CLI sku ids (e.g. `gpt-5.5-high-fast` → `gpt-5.5`). */
const CLI_SKU_SUFFIXES = [
'-extra-high-fast',
'-extra-high',
'-xhigh-fast',
'-xhigh',
'-high-fast',
'-high',
'-medium-fast',
'-medium',
'-low-fast',
'-low',
'-none-fast',
'-none',
'-thinking-high-fast',
'-thinking-high',
'-thinking',
'-fast',
] as const;
export function cursorCliSkuBaseId(slug: string): string {
const trimmed = slug.trim();
if (!trimmed || isCursorAcpWireModelId(trimmed)) {
return cursorModelBaseId(trimmed);
}
let base = trimmed;
let changed = true;
while (changed) {
changed = false;
for (const suffix of CLI_SKU_SUFFIXES) {
if (base.endsWith(suffix)) {
base = base.slice(0, -suffix.length);
changed = true;
break;
}
}
}
return base;
}
/**
* CLI probe SKUs that carry effort/speed suffixes (e.g. `gpt-5.5-high-fast`).
* Base-only slugs like `composer-2.5` are not variant SKUs.
*/
export function isCursorCliSkuVariantId(modelId: string): boolean {
const trimmed = modelId.trim();
if (!trimmed || isCursorAcpWireModelId(trimmed)) {
return false;
}
return cursorCliSkuBaseId(trimmed) !== trimmed;
}
/**
* Picker/catalog-eligible ACP model ids.
* Accepts parameterized wires and bare non-default ACP bases (current Cursor ACP).
* Rejects CLI effort/speed SKU slugs so they stay variant rows, not top-level bases.
*/
export function isCursorAcpCatalogModelId(modelId: string): boolean {
const trimmed = modelId.trim();
if (!trimmed) {
return false;
}
const lower = trimmed.toLowerCase();
if (lower === 'auto' || lower === 'default') {
return false;
}
if (isCursorAcpWireModelId(trimmed)) {
return true;
}
if (isCursorCliSkuVariantId(trimmed)) {
return false;
}
return true;
}
export function parseCursorWireParams(modelId: string): Record<string, string> {
const variant = modelId.includes('[') ? modelId.slice(modelId.indexOf('[') + 1).replace(/\]$/, '') : '';
if (!variant) {
return {};
}
const params: Record<string, string> = {};
for (const part of variant.split(',')) {
const segment = part.trim();
if (!segment) continue;
const eq = segment.indexOf('=');
if (eq === -1) {
params[segment] = 'true';
continue;
}
params[segment.slice(0, eq).trim()] = segment.slice(eq + 1).trim();
}
return params;
}
/**
* Parameter hints carried by a CLI `agent --list-models` sku
* (effort/reasoning/thinking/fast suffixes).
*/
export function parseCursorSkuParamHints(slug: string): Record<string, string> {
return inferSkuParamHints(slug);
}
function inferSkuParamHints(slug: string): Record<string, string> {
const lower = slug.toLowerCase();
const hints: Record<string, string> = {};
if (lower.includes('extra-high') || lower.includes('xhigh')) {
hints.reasoning = 'extra-high';
hints.effort = 'xhigh';
} else if (lower.includes('-high')) {
hints.reasoning = 'high';
hints.effort = 'high';
} else if (lower.includes('-low')) {
hints.reasoning = 'low';
hints.effort = 'low';
} else if (lower.includes('-medium')) {
hints.reasoning = 'medium';
hints.effort = 'medium';
} else if (lower.includes('-none')) {
hints.reasoning = 'none';
}
// Cursor CLI convention: `-fast` suffix means fast=true; absence means fast=false.
// Without an explicit hint, base-only SKUs (e.g. `composer-2.5`) would tie-break to the
// first wire and silently coerce to the fast variant.
hints.fast = lower.includes('-fast') ? 'true' : 'false';
if (lower.includes('thinking')) {
hints.thinking = 'true';
}
return hints;
}
function scoreWireAgainstSku(slug: string, wireId: string): number {
const hints = inferSkuParamHints(slug);
const params = parseCursorWireParams(wireId);
let score = 0;
for (const [key, value] of Object.entries(hints)) {
if (params[key] === value) {
score += 2;
} else if (params[key] !== undefined) {
score -= 1;
}
}
return score;
}
/** Best-matching CLI sku for highlighting when session state stores an ACP wire id. */
export function findBestCliSkuForAcpWire(
wireId: string,
skuIds: readonly string[]
): string | null {
let best: string | null = null;
let bestScore = Number.NEGATIVE_INFINITY;
for (const sku of skuIds) {
const trimmed = sku.trim();
if (!trimmed || isCursorAcpWireModelId(trimmed)) {
continue;
}
if (matchCliSkuToAcpWireId(trimmed, [{ modelId: wireId }]) !== wireId) {
continue;
}
const score = scoreWireAgainstSku(trimmed, wireId);
if (score > bestScore) {
bestScore = score;
best = trimmed;
}
}
return best;
}
function syntheticSkuFromWireParams(base: string, params: Record<string, string>): string {
const effort = params.reasoning ?? params.effort ?? 'medium';
let sku = resolveCursorLegacyModelBase(base);
if (effort === 'extra-high' || effort === 'xhigh') {
sku += '-extra-high';
} else if (effort === 'high') {
sku += '-high';
} else if (effort === 'low') {
sku += '-low';
} else if (effort === 'medium') {
sku += '-medium';
} else if (effort === 'none') {
sku += '-none';
}
if (params.fast === 'true') {
sku += '-fast';
}
return sku;
}
function pseudoWireFromSku(sku: string): string {
const base = cursorCliSkuBaseId(sku);
const hints = inferSkuParamHints(sku);
const parts = Object.entries(hints).map(([key, value]) => `${key}=${value}`);
return `${base}[${parts.join(',')}]`;
}
function pickBestCatalogSku(
requestedSku: string,
available: readonly { modelId: string }[]
): string | null {
const skuBase = cursorCliSkuBaseId(requestedSku);
const candidates = available.filter((entry) => {
const modelId = entry.modelId.trim();
return modelId && !isCursorAcpWireModelId(modelId) && cursorCliSkuBaseId(modelId) === skuBase;
});
if (candidates.length === 0) {
return null;
}
if (candidates.length === 1) {
return candidates[0].modelId;
}
const exact = candidates.find((entry) => entry.modelId === requestedSku);
if (exact) {
return exact.modelId;
}
const pseudoWire = pseudoWireFromSku(requestedSku);
const requestedFast = inferSkuParamHints(requestedSku).fast;
const sameSpeed = candidates.filter(
(entry) => inferSkuParamHints(entry.modelId).fast === requestedFast
);
const rankedCandidates = sameSpeed.length > 0 ? sameSpeed : candidates;
let best = rankedCandidates[0].modelId;
let bestScore = Number.NEGATIVE_INFINITY;
for (const entry of rankedCandidates) {
const score = scoreWireAgainstSku(entry.modelId, pseudoWire);
if (score > bestScore) {
bestScore = score;
best = entry.modelId;
}
}
return best;
}
/** Parse model ids from Cursor `Cannot use this model` stderr (Available models: …). */
export function parseCursorAvailableModelsFromRejection(text: string): string[] {
const match = text.match(/Available models:\s*([^\n]*)/i);
if (!match) {
return [];
}
let catalog = match[1].trim();
const tipIdx = catalog.search(/\s+Tip:/i);
if (tipIdx !== -1) {
catalog = catalog.slice(0, tipIdx).trim();
}
return catalog
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0 && part.toLowerCase() !== 'auto');
}
function rewriteSkuBase(sku: string, fromBase: string, toBase: string): string {
if (fromBase === toBase || !sku.startsWith(fromBase)) {
return sku;
}
return `${toBase}${sku.slice(fromBase.length)}`;
}
/**
* Prefer a `--model`-safe catalog id (bare base or CLI SKU) over an ACP wire.
* Cursor rejects many bracketed wires on process start even when session/new
* later exposes parameterized `currentModelId` wires.
*/
function preferSpawnSafeCatalogId(
candidate: string,
requestedWire: string,
available: readonly { modelId: string }[]
): string {
if (!isCursorAcpWireModelId(candidate)) {
return candidate;
}
const wireBase = cursorModelBaseId(requestedWire);
const legacyBase = resolveCursorLegacyModelBase(wireBase);
const syntheticSku = rewriteSkuBase(
syntheticSkuFromWireParams(wireBase, parseCursorWireParams(requestedWire)),
wireBase,
legacyBase
);
const fromSku = pickBestCatalogSku(syntheticSku, available);
if (fromSku) {
return fromSku;
}
for (const base of [legacyBase, wireBase]) {
const bare = available.find(
(entry) => entry.modelId === base && !isCursorAcpWireModelId(entry.modelId)
);
if (bare) {
return bare.modelId;
}
}
return candidate;
}
function pickBestCatalogWire(
requestedWire: string,
available: readonly { modelId: string }[]
): string | null {
const wireBase = cursorModelBaseId(requestedWire);
const legacyBase = resolveCursorLegacyModelBase(wireBase);
const acceptedBases = new Set([wireBase, legacyBase]);
const requestedParams = Object.entries(parseCursorWireParams(requestedWire));
const wires = available.filter((entry) => {
const modelId = entry.modelId.trim();
if (
!modelId
|| !isCursorAcpWireModelId(modelId)
|| !acceptedBases.has(resolveCursorLegacyModelBase(cursorModelBaseId(modelId)))
) {
return false;
}
// Every explicit request param must be present on the candidate (do not
// drop context/thinking/etc. via synthetic-SKU scoring alone).
if (requestedParams.length === 0) {
return true;
}
const candidateParams = parseCursorWireParams(modelId);
return requestedParams.every(([key, value]) => candidateParams[key] === value);
});
if (wires.length === 0) {
return null;
}
if (wires.length === 1) {
return wires[0].modelId;
}
const syntheticSku = rewriteSkuBase(
syntheticSkuFromWireParams(wireBase, parseCursorWireParams(requestedWire)),
wireBase,
legacyBase
);
let best = wires[0].modelId;
let bestScore = Number.NEGATIVE_INFINITY;
for (const entry of wires) {
const score = scoreWireAgainstSku(syntheticSku, entry.modelId);
if (score > bestScore) {
bestScore = score;
best = entry.modelId;
}
}
return best;
}
/**
* Remap a stale hub/ACP wire id onto a live Cursor catalog entry.
* Returns null when no catalog candidate matches.
*/
export function remapStaleCursorModelId(
requested: string,
available: readonly { modelId: string }[]
): string | null {
const trimmed = requested.trim();
if (!trimmed) {
return null;
}
const exact = available.find((entry) => entry.modelId === trimmed);
const legacyWire = isCursorAcpWireModelId(trimmed)
&& resolveCursorLegacyModelBase(cursorModelBaseId(trimmed)) !== cursorModelBaseId(trimmed);
if (exact && !legacyWire) {
// Mixed caches often list the exact ACP wire *and* a CLI SKU. Prefer the
// spawn-safe id so `agent --model` does not receive a rejected bracket wire.
return isCursorAcpWireModelId(trimmed)
? preferSpawnSafeCatalogId(trimmed, trimmed, available)
: trimmed;
}
if (isCursorAcpWireModelId(trimmed)) {
const wireBase = cursorModelBaseId(trimmed);
const legacyBase = resolveCursorLegacyModelBase(wireBase);
const syntheticSku = rewriteSkuBase(
syntheticSkuFromWireParams(wireBase, parseCursorWireParams(trimmed)),
wireBase,
legacyBase
);
// Prefer bare / CLI SKU rows (what `agent --model … acp` accepts).
const fromSku = pickBestCatalogSku(syntheticSku, available);
if (fromSku) {
return fromSku;
}
for (const base of [legacyBase, wireBase]) {
const bare = available.find(
(entry) => entry.modelId === base && !isCursorAcpWireModelId(entry.modelId)
);
if (bare) {
return bare.modelId;
}
}
// Wire-only catalogs (session configOptions): nearest compatible same-base wire.
// Do not fall through to SKU→wire matching — that drops explicit params like
// thinking/context and can pick a contradictory variant.
const fromWire = pickBestCatalogWire(trimmed, available);
return fromWire ? preferSpawnSafeCatalogId(fromWire, trimmed, available) : null;
}
const legacyBase = resolveCursorLegacyModelBase(cursorCliSkuBaseId(trimmed));
if (legacyBase !== cursorCliSkuBaseId(trimmed)) {
const rewritten = trimmed.replace(cursorCliSkuBaseId(trimmed), legacyBase);
return pickBestCatalogSku(rewritten, available)
?? matchCliSkuToAcpWireId(rewritten, available);
}
// Non-legacy, non-wire ids must remain exact; let Cursor report unavailability
// instead of silently downgrading e.g. gpt-5.5-high-fast → gpt-5.5-medium.
return null;
}
/**
* Map UI/CLI model id (wire or slug) onto an ACP configOptions wire id.
*/
export function matchCliSkuToAcpWireId(
requested: string,
available: readonly { modelId: string }[]
): string | null {
const trimmed = requested.trim();
if (!trimmed) {
return null;
}
const exact = available.find((entry) => entry.modelId === trimmed);
if (exact) {
return exact.modelId;
}
if (isCursorAcpWireModelId(trimmed)) {
// Bracketed hub wires (including non-legacy) must remap onto live catalog rows.
return remapStaleCursorModelId(trimmed, available);
}
const skuBase = cursorCliSkuBaseId(trimmed);
const wires = available.filter(
(entry) => isCursorAcpCatalogModelId(entry.modelId) && cursorModelBaseId(entry.modelId) === skuBase
);
if (wires.length === 0) {
return null;
}
// Suffixed SKUs must not collapse onto a bare-only ACP catalog — those rows
// cannot express effort/speed (apply is model + fast on parameterized wires).
const hasParameterizedWire = wires.some((entry) => isCursorAcpWireModelId(entry.modelId));
if (isCursorCliSkuVariantId(trimmed) && !hasParameterizedWire) {
return null;
}
if (wires.length === 1) {
return wires[0].modelId;
}
let best = wires[0].modelId;
let bestScore = Number.NEGATIVE_INFINITY;
for (const entry of wires) {
const score = scoreWireAgainstSku(trimmed, entry.modelId);
if (score > bestScore) {
bestScore = score;
best = entry.modelId;
}
}
return best;
}