Skip to content

Commit ca1fd80

Browse files
swear01cursoragent
andauthored
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>
1 parent 8f9d6bd commit ca1fd80

34 files changed

Lines changed: 977 additions & 199 deletions

‎cli/src/cursor/cursorAcpRemoteLauncher.test.ts‎

Lines changed: 222 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const harness = vi.hoisted(() => ({
1010
loadSessionError: null as Error | null,
1111
newSessionError: null as Error | null,
1212
failSetConfigOption: false,
13+
failFastConfigOption: false,
1314
supportsLoadSession: true,
1415
loadSessionCalled: false,
1516
newSessionCalled: false,
@@ -26,6 +27,15 @@ const harness = vi.hoisted(() => ({
2627
promptStderrErrors: [] as Array<{ type: string; message: string; raw: string }>,
2728
releasePrompt: null as (() => void) | null,
2829
backendArgs: null as { command: string; args?: string[] } | null,
30+
modelOptionValues: [
31+
'default[]',
32+
'composer-2.5[fast=true]',
33+
'composer-2.5[fast=false]',
34+
'gpt-5.3-codex[reasoning=medium,fast=false]',
35+
'gpt-5.3-codex[reasoning=medium,fast=true]',
36+
'cursor-grok-4.5-medium',
37+
'cursor-grok-4.5-medium-fast',
38+
] as string[],
2939
setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>,
3040
deferSetConfigOption: null as Promise<void> | null,
3141
releaseSetConfigOption: null as (() => void) | null,
@@ -48,8 +58,11 @@ vi.mock('./utils/cursorAcpBackend', () => ({
4858
createCursorAcpBackend: vi.fn((opts?: { model?: string | null }) => {
4959
const args = ['acp'];
5060
const model = opts?.model?.trim();
51-
if (model && model !== 'auto' && model !== 'default' && model !== 'default[]') {
52-
args.unshift('--model', model);
61+
const spawn = !model
62+
? null
63+
: (model === 'auto' || model === 'default' || model === 'default[]' ? 'auto' : model);
64+
if (spawn) {
65+
args.unshift('--model', spawn);
5366
}
5467
harness.backendArgs = { command: 'agent', args };
5568
return {
@@ -96,6 +109,9 @@ vi.mock('./utils/cursorAcpBackend', () => ({
96109
if (harness.failSetConfigOption && configId === 'model-opt') {
97110
throw new Error('set_config_option rejected');
98111
}
112+
if (harness.failFastConfigOption && configId === 'fast') {
113+
throw new Error('fast update rejected');
114+
}
99115
harness.setConfigOptionCalls.push({ sessionId, configId, value });
100116
}),
101117
pinSessionModelWireId: vi.fn(),
@@ -111,6 +127,12 @@ vi.mock('./utils/cursorAcpBackend', () => ({
111127
currentModelId: 'composer-2.5[fast=true]'
112128
})),
113129
getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => {
130+
if (category === 'fast' && harness.failFastConfigOption) {
131+
return {
132+
id: 'fast',
133+
options: [{ value: 'false' }, { value: 'true' }]
134+
};
135+
}
114136
if (category === 'mode') {
115137
return {
116138
id: 'mode-opt',
@@ -124,15 +146,7 @@ vi.mock('./utils/cursorAcpBackend', () => ({
124146
if (category === 'model') {
125147
return {
126148
id: 'model-opt',
127-
options: [
128-
{ value: 'default[]' },
129-
{ value: 'composer-2.5[fast=true]' },
130-
{ value: 'composer-2.5[fast=false]' },
131-
{ value: 'gpt-5.3-codex[reasoning=medium,fast=false]' },
132-
{ value: 'gpt-5.3-codex[reasoning=medium,fast=true]' },
133-
{ value: 'cursor-grok-4.5-medium' },
134-
{ value: 'cursor-grok-4.5-medium-fast' },
135-
]
149+
options: harness.modelOptionValues.map((value) => ({ value }))
136150
};
137151
}
138152
return undefined;
@@ -285,6 +299,16 @@ describe('cursorAcpRemoteLauncher', () => {
285299
harness.loadSessionError = null;
286300
harness.newSessionError = null;
287301
harness.failSetConfigOption = false;
302+
harness.failFastConfigOption = false;
303+
harness.modelOptionValues = [
304+
'default[]',
305+
'composer-2.5[fast=true]',
306+
'composer-2.5[fast=false]',
307+
'gpt-5.3-codex[reasoning=medium,fast=false]',
308+
'gpt-5.3-codex[reasoning=medium,fast=true]',
309+
'cursor-grok-4.5-medium',
310+
'cursor-grok-4.5-medium-fast',
311+
];
288312
harness.supportsLoadSession = true;
289313
harness.loadSessionCalled = false;
290314
harness.newSessionCalled = false;
@@ -596,6 +620,15 @@ describe('cursorAcpRemoteLauncher', () => {
596620
expect(legacyLauncher).not.toHaveBeenCalled();
597621
});
598622

623+
it('pins CLI auto with --model auto on spawn', async () => {
624+
const session = makeSession(null);
625+
session.model = 'auto';
626+
await cursorAcpRemoteLauncher(session);
627+
628+
expect(harness.backendArgs).toEqual({ command: 'agent', args: ['--model', 'auto', 'acp'] });
629+
expect(session.model).toBe('auto');
630+
});
631+
599632
it('applies harness thinking transitions once per edge (#1470)', async () => {
600633
const keepAlive = vi.fn();
601634
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
@@ -1533,7 +1566,7 @@ describe('cursorAcpRemoteLauncher', () => {
15331566
await runPromise;
15341567
});
15351568

1536-
it('applies ACP default model when setModel is cleared', async () => {
1569+
it('does not claim live Auto when ACP has no auto option', async () => {
15371570
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
15381571
const client = {
15391572
rpcHandlerManager: { registerHandler: vi.fn() },
@@ -1575,16 +1608,14 @@ describe('cursorAcpRemoteLauncher', () => {
15751608
});
15761609

15771610
harness.setConfigOptionCalls.length = 0;
1578-
session.setModel(null);
1611+
session.setModel('auto');
15791612

15801613
await vi.waitFor(() => {
1581-
expect(
1582-
harness.setConfigOptionCalls.some(
1583-
(call) => call.configId === 'model-opt' && call.value === 'default[]'
1584-
)
1585-
).toBe(true);
1586-
expect(session.model).toBeUndefined();
1614+
expect(session.model).toBe('composer-2.5[fast=false]');
15871615
});
1616+
await new Promise((resolve) => setTimeout(resolve, 30));
1617+
expect(session.model).toBe('composer-2.5[fast=false]');
1618+
expect(harness.setConfigOptionCalls.filter((call) => call.configId === 'model-opt')).toEqual([]);
15881619

15891620
queue.close();
15901621
await runPromise;
@@ -1634,7 +1665,7 @@ describe('cursorAcpRemoteLauncher', () => {
16341665
await runPromise;
16351666
});
16361667

1637-
it('applyModelConfig(null) resets ACP to the default model option', async () => {
1668+
it('rejects live applyModelConfig Auto when ACP has no auto option', async () => {
16381669
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
16391670
const client = {
16401671
rpcHandlerManager: { registerHandler: vi.fn() },
@@ -1669,18 +1700,187 @@ describe('cursorAcpRemoteLauncher', () => {
16691700
await session.applyModelConfig('composer-2.5[fast=false]');
16701701
harness.setConfigOptionCalls.length = 0;
16711702

1672-
await session.applyModelConfig(null);
1703+
await expect(session.applyModelConfig('auto')).rejects.toThrow(
1704+
'Cursor Auto requires restarting with --model auto'
1705+
);
1706+
1707+
expect(session.model).toBe('composer-2.5[fast=false]');
1708+
expect(harness.setConfigOptionCalls.filter((call) => call.configId === 'model-opt')).toEqual([]);
1709+
1710+
queue.close();
1711+
await runPromise;
1712+
});
1713+
1714+
it('applies Auto via ACP when the catalog advertises auto', async () => {
1715+
harness.modelOptionValues = ['auto', 'composer-2.5[fast=false]'];
1716+
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
1717+
const client = {
1718+
rpcHandlerManager: { registerHandler: vi.fn() },
1719+
updateMetadata: vi.fn(),
1720+
flushMetadata: vi.fn(async () => true),
1721+
sendSessionEvent: vi.fn(),
1722+
sendAgentMessage: vi.fn(),
1723+
keepAlive: vi.fn(),
1724+
emitSessionReady: vi.fn()
1725+
} as unknown as ApiSessionClient;
16731726

1727+
const session = new CursorSession({
1728+
api: {} as never,
1729+
client,
1730+
path: '/tmp/project',
1731+
logPath: '/tmp/log',
1732+
sessionId: null,
1733+
messageQueue: queue,
1734+
onModeChange: vi.fn(),
1735+
mode: 'remote',
1736+
startedBy: 'runner',
1737+
startingMode: 'remote',
1738+
permissionMode: 'default'
1739+
});
1740+
session.onSessionFoundWithProtocol = vi.fn();
1741+
queue.push('hold-open', { permissionMode: 'default' });
1742+
1743+
const runPromise = cursorAcpRemoteLauncher(session);
1744+
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
1745+
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
1746+
1747+
await session.applyModelConfig('composer-2.5[fast=false]');
1748+
harness.setConfigOptionCalls.length = 0;
1749+
1750+
await session.applyModelConfig('auto');
1751+
1752+
expect(session.model).toBe('auto');
16741753
expect(
16751754
harness.setConfigOptionCalls.some(
1676-
(call) => call.configId === 'model-opt' && call.value === 'default[]'
1755+
(call) => call.configId === 'model-opt' && call.value === 'auto'
16771756
)
16781757
).toBe(true);
16791758

16801759
queue.close();
16811760
await runPromise;
16821761
});
16831762

1763+
it('rejects applyModelConfig Auto when advertised auto cannot be set', async () => {
1764+
harness.modelOptionValues = ['auto', 'composer-2.5[fast=false]'];
1765+
harness.failSetConfigOption = true;
1766+
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
1767+
const client = {
1768+
rpcHandlerManager: { registerHandler: vi.fn() },
1769+
updateMetadata: vi.fn(),
1770+
flushMetadata: vi.fn(async () => true),
1771+
sendSessionEvent: vi.fn(),
1772+
sendAgentMessage: vi.fn(),
1773+
keepAlive: vi.fn(),
1774+
emitSessionReady: vi.fn()
1775+
} as unknown as ApiSessionClient;
1776+
1777+
const session = new CursorSession({
1778+
api: {} as never,
1779+
client,
1780+
path: '/tmp/project',
1781+
logPath: '/tmp/log',
1782+
sessionId: null,
1783+
messageQueue: queue,
1784+
onModeChange: vi.fn(),
1785+
mode: 'remote',
1786+
startedBy: 'runner',
1787+
startingMode: 'remote',
1788+
permissionMode: 'default'
1789+
});
1790+
session.onSessionFoundWithProtocol = vi.fn();
1791+
queue.push('hold-open', { permissionMode: 'default' });
1792+
1793+
const runPromise = cursorAcpRemoteLauncher(session);
1794+
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
1795+
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
1796+
1797+
await expect(session.applyModelConfig('auto')).rejects.toThrow(
1798+
'Cursor auto model is not available via ACP'
1799+
);
1800+
expect(session.model).not.toBe('auto');
1801+
1802+
queue.close();
1803+
await runPromise;
1804+
});
1805+
1806+
it.each([[false, false], [true, false], [false, true], [true, true]])('rejects Auto after an Auto-spawned concrete switch (partial=%s, concurrent=%s)', async (partial, concurrent) => {
1807+
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
1808+
const client = {
1809+
rpcHandlerManager: { registerHandler: vi.fn() },
1810+
updateMetadata: vi.fn(),
1811+
flushMetadata: vi.fn(async () => true),
1812+
sendSessionEvent: vi.fn(),
1813+
sendAgentMessage: vi.fn(),
1814+
keepAlive: vi.fn(),
1815+
emitSessionReady: vi.fn()
1816+
} as unknown as ApiSessionClient;
1817+
1818+
const session = new CursorSession({
1819+
api: {} as never,
1820+
client,
1821+
path: '/tmp/project',
1822+
logPath: '/tmp/log',
1823+
sessionId: null,
1824+
messageQueue: queue,
1825+
onModeChange: vi.fn(),
1826+
mode: 'remote',
1827+
startedBy: 'runner',
1828+
startingMode: 'remote',
1829+
permissionMode: 'default',
1830+
model: 'auto'
1831+
});
1832+
session.onSessionFoundWithProtocol = vi.fn();
1833+
queue.push('hold-open', { permissionMode: 'default' });
1834+
1835+
const runPromise = cursorAcpRemoteLauncher(session);
1836+
await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true));
1837+
await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true));
1838+
expect(harness.backendArgs).toEqual({ command: 'agent', args: ['--model', 'auto', 'acp'] });
1839+
1840+
if (partial) {
1841+
harness.modelOptionValues = ['default', 'composer-2.5'];
1842+
harness.failFastConfigOption = true;
1843+
}
1844+
if (concurrent) {
1845+
harness.deferSetConfigOption = new Promise<void>((resolve) => {
1846+
harness.releaseSetConfigOption = resolve;
1847+
});
1848+
}
1849+
const concreteRequest = session.applyModelConfig('composer-2.5[fast=false]');
1850+
const autoRequest = concurrent
1851+
? session.applyModelConfig('auto').then(
1852+
value => ({ value, error: null }),
1853+
error => ({ value: null, error })
1854+
)
1855+
: null;
1856+
if (concurrent) {
1857+
await new Promise<void>(resolve => setImmediate(resolve));
1858+
harness.releaseSetConfigOption?.();
1859+
}
1860+
if (partial) {
1861+
await expect(concreteRequest).rejects.toThrow(
1862+
'Cursor model is not available via ACP'
1863+
);
1864+
} else {
1865+
await concreteRequest;
1866+
}
1867+
if (autoRequest) {
1868+
expect((await autoRequest).error?.message).toContain('Cursor Auto requires restarting with --model auto');
1869+
}
1870+
const concreteModel = partial ? 'composer-2.5' : 'composer-2.5[fast=false]';
1871+
expect(session.model).toBe(concreteModel);
1872+
harness.setConfigOptionCalls.length = 0;
1873+
1874+
await expect(session.applyModelConfig('auto')).rejects.toThrow(
1875+
'Cursor Auto requires restarting with --model auto'
1876+
);
1877+
expect(session.model).toBe(concreteModel);
1878+
expect(harness.setConfigOptionCalls.filter((call) => call.configId === 'model-opt')).toEqual([]);
1879+
1880+
queue.close();
1881+
await runPromise;
1882+
});
1883+
16841884
it('rejects applyModelConfig when ACP does not expose the requested model', async () => {
16851885
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
16861886
const client = {

0 commit comments

Comments
 (0)