Files
hapi/shared/src/cursorCliSku.ts
T
fe4d51043c fix(cli): remap bracketed Cursor wires onto bare/SKU catalogs (#1430)
* fix(cli): remap bracketed Cursor wires onto bare/SKU catalogs

#1271 only handled the grok-4.5 legacy alias family. Persisted wires like
gpt-5.3-codex[fast=false] still failed agent --model on resume against
today's bare ACP catalogs. Remap non-legacy wires to bare bases / CLI
SKUs (and nearest configOption wires), and prefer spawn-safe ids in
session state.

Fixes #1428

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

* fix(cli): harden Cursor stale-model remap against silent wrong picks

Address Codex Major review on #1430: prefer spawn-safe ids even on exact
bracket cache hits, require explicit wire params when ranking configOption
candidates, and stop downgrading unavailable CLI SKU variants.

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

* fix(cli): preserve desired Cursor variant across spawn-safe remap

Do not overwrite session.model with the bare/SKU spawn id before ACP
apply, and keep spawn-safe requested ids in wireIdForCursorSessionState
so apply does not re-poison hub state with bracket wires.

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

* fix(cli): remap-retry Cursor session/new on stale model reject

Initialize and session/load already retried once after Cannot use this
model; session/new had the same failure mode with an empty shared cache.
Mirror the one-shot remap+respawn path and cover it with a launcher test.

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

* fix(cli): resolve Cursor apply against ACP option values first

Spawn-safe remap prefers bare/SKU ids, but set_config_option may only
accept full bracket wires. Resolve against the model option list before
falling back to metadata so apply does not pick a rejected bare base.

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

* fix(cli): fail Cursor launch when remapped spawn cannot restore model

When --model was remapped for spawn, restoring the original desired
variant via ACP is mandatory. Soft-failing left sessions running on the
wrong model. Cover success and hard-fail paths in launcher tests.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 20:49:42 +01:00

493 lines
16 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;
}
/** 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('[');
}
export function cursorModelBaseId(modelId: string): string {
const trimmed = modelId.trim();
const bracket = trimmed.indexOf('[');
return bracket === -1 ? trimmed : trimmed.slice(0, bracket);
}
/** 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;
}
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;
}