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>
This commit is contained in:
SSU-WEI HUANG
2026-09-16 08:12:16 +08:00
committed by GitHub
co-authored by Cursor
parent 8f9d6bd7f8
commit ca1fd80afd
34 changed files with 977 additions and 199 deletions
+9 -1
View File
@@ -877,7 +877,15 @@ export type ListAgyModelsResponse = AgyModelsResponse
export type CursorModelSummary = OpencodeModelSummary
export type CursorModelsResponse = OpencodeModelsResponse
export type CursorModelsResponse = OpencodeModelsResponse & {
/**
* True when ACP advertised Cursor's parameterized model picker: bare model bases
* plus separate `fast` / `thought_level` config options. The ACP apply path
* expresses variant CLI skus through those options, so variant rows stay valid
* even when the catalog itself carries no bracket wire ids.
*/
parameterized?: boolean
}
export type ListCursorModelsResponse = CursorModelsResponse
+41
View File
@@ -1,12 +1,17 @@
import { describe, expect, it } from 'vitest';
import {
CURSOR_AUTO_MODEL_ID,
cursorCliSkuBaseId,
cursorSpawnModelId,
cursorModelBaseMatches,
findBestCliSkuForAcpWire,
isCursorAcpCatalogModelId,
isCursorAcpWireModelId,
isCursorAutoModelId,
isCursorCliSkuVariantId,
matchCliSkuToAcpWireId,
parseCursorAvailableModelsFromRejection,
parseCursorSkuParamHints,
remapStaleCursorModelId
} from './cursorCliSku';
@@ -19,6 +24,19 @@ const cursorGrokCatalog = [
{ modelId: 'cursor-grok-4.5-high-fast' },
];
describe('cursorSpawnModelId', () => {
it('pins Auto as the CLI auto id and omits unset models', () => {
expect(isCursorAutoModelId('auto')).toBe(true);
expect(isCursorAutoModelId('default[]')).toBe(true);
expect(isCursorAutoModelId('composer-2.5')).toBe(false);
expect(cursorSpawnModelId('auto')).toBe(CURSOR_AUTO_MODEL_ID);
expect(cursorSpawnModelId('default')).toBe(CURSOR_AUTO_MODEL_ID);
expect(cursorSpawnModelId('default[]')).toBe(CURSOR_AUTO_MODEL_ID);
expect(cursorSpawnModelId(null)).toBeNull();
expect(cursorSpawnModelId('composer-2.5[fast=true]')).toBe('composer-2.5[fast=true]');
});
});
describe('cursorCliSkuBaseId', () => {
it('strips effort/speed suffixes from CLI skus', () => {
expect(cursorCliSkuBaseId('gpt-5.5-high-fast')).toBe('gpt-5.5');
@@ -273,6 +291,29 @@ describe('round-trip (regression for #883: "selected but no response")', () => {
});
});
describe('cursorModelBaseMatches', () => {
it('matches bases across the legacy cursor- CLI family prefix', () => {
expect(cursorModelBaseMatches('cursor-grok-4.6', 'grok-4.6')).toBe(true);
expect(cursorModelBaseMatches('grok-4.6', 'cursor-grok-4.6')).toBe(true);
expect(cursorModelBaseMatches('cursor-grok-4.5', 'cursor-grok-4.5')).toBe(true);
expect(cursorModelBaseMatches('composer-2.5', 'composer-2.5')).toBe(true);
expect(cursorModelBaseMatches('grok-4.6', 'grok-4.5')).toBe(false);
expect(cursorModelBaseMatches('claude-opus-4-8', 'claude-opus-5')).toBe(false);
});
it('parses parameter hints from CLI sku suffixes', () => {
expect(parseCursorSkuParamHints('cursor-grok-4.6-low')).toMatchObject({
effort: 'low',
fast: 'false'
});
expect(parseCursorSkuParamHints('gpt-5.5-extra-high-fast')).toMatchObject({
reasoning: 'extra-high',
fast: 'true'
});
expect(parseCursorSkuParamHints('composer-2.5')).toMatchObject({ fast: 'false' });
});
});
describe('isCursorAcpWireModelId', () => {
it('detects parameterized wire ids only', () => {
expect(isCursorAcpWireModelId('gpt-5.5[fast=false]')).toBe(true);
+44
View File
@@ -8,18 +8,54 @@ export function resolveCursorLegacyModelBase(baseId: string): string {
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',
@@ -116,6 +152,14 @@ export function parseCursorWireParams(modelId: string): Record<string, string> {
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> = {};