diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index 010b4bd0..bc283a72 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -10,6 +10,7 @@ const harness = vi.hoisted(() => ({ loadSessionError: null as Error | null, newSessionError: null as Error | null, failSetConfigOption: false, + failFastConfigOption: false, supportsLoadSession: true, loadSessionCalled: false, newSessionCalled: false, @@ -26,6 +27,15 @@ const harness = vi.hoisted(() => ({ promptStderrErrors: [] as Array<{ type: string; message: string; raw: string }>, releasePrompt: null as (() => void) | null, backendArgs: null as { command: string; args?: string[] } | null, + modelOptionValues: [ + 'default[]', + 'composer-2.5[fast=true]', + 'composer-2.5[fast=false]', + 'gpt-5.3-codex[reasoning=medium,fast=false]', + 'gpt-5.3-codex[reasoning=medium,fast=true]', + 'cursor-grok-4.5-medium', + 'cursor-grok-4.5-medium-fast', + ] as string[], setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>, deferSetConfigOption: null as Promise | null, releaseSetConfigOption: null as (() => void) | null, @@ -48,8 +58,11 @@ vi.mock('./utils/cursorAcpBackend', () => ({ createCursorAcpBackend: vi.fn((opts?: { model?: string | null }) => { const args = ['acp']; const model = opts?.model?.trim(); - if (model && model !== 'auto' && model !== 'default' && model !== 'default[]') { - args.unshift('--model', model); + const spawn = !model + ? null + : (model === 'auto' || model === 'default' || model === 'default[]' ? 'auto' : model); + if (spawn) { + args.unshift('--model', spawn); } harness.backendArgs = { command: 'agent', args }; return { @@ -96,6 +109,9 @@ vi.mock('./utils/cursorAcpBackend', () => ({ if (harness.failSetConfigOption && configId === 'model-opt') { throw new Error('set_config_option rejected'); } + if (harness.failFastConfigOption && configId === 'fast') { + throw new Error('fast update rejected'); + } harness.setConfigOptionCalls.push({ sessionId, configId, value }); }), pinSessionModelWireId: vi.fn(), @@ -111,6 +127,12 @@ vi.mock('./utils/cursorAcpBackend', () => ({ currentModelId: 'composer-2.5[fast=true]' })), getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'fast' && harness.failFastConfigOption) { + return { + id: 'fast', + options: [{ value: 'false' }, { value: 'true' }] + }; + } if (category === 'mode') { return { id: 'mode-opt', @@ -124,15 +146,7 @@ vi.mock('./utils/cursorAcpBackend', () => ({ if (category === 'model') { return { id: 'model-opt', - options: [ - { value: 'default[]' }, - { value: 'composer-2.5[fast=true]' }, - { value: 'composer-2.5[fast=false]' }, - { value: 'gpt-5.3-codex[reasoning=medium,fast=false]' }, - { value: 'gpt-5.3-codex[reasoning=medium,fast=true]' }, - { value: 'cursor-grok-4.5-medium' }, - { value: 'cursor-grok-4.5-medium-fast' }, - ] + options: harness.modelOptionValues.map((value) => ({ value })) }; } return undefined; @@ -285,6 +299,16 @@ describe('cursorAcpRemoteLauncher', () => { harness.loadSessionError = null; harness.newSessionError = null; harness.failSetConfigOption = false; + harness.failFastConfigOption = false; + harness.modelOptionValues = [ + 'default[]', + 'composer-2.5[fast=true]', + 'composer-2.5[fast=false]', + 'gpt-5.3-codex[reasoning=medium,fast=false]', + 'gpt-5.3-codex[reasoning=medium,fast=true]', + 'cursor-grok-4.5-medium', + 'cursor-grok-4.5-medium-fast', + ]; harness.supportsLoadSession = true; harness.loadSessionCalled = false; harness.newSessionCalled = false; @@ -596,6 +620,15 @@ describe('cursorAcpRemoteLauncher', () => { expect(legacyLauncher).not.toHaveBeenCalled(); }); + it('pins CLI auto with --model auto on spawn', async () => { + const session = makeSession(null); + session.model = 'auto'; + await cursorAcpRemoteLauncher(session); + + expect(harness.backendArgs).toEqual({ command: 'agent', args: ['--model', 'auto', 'acp'] }); + expect(session.model).toBe('auto'); + }); + it('applies harness thinking transitions once per edge (#1470)', async () => { const keepAlive = vi.fn(); const queue = new MessageQueue2(() => 'mode'); @@ -1533,7 +1566,7 @@ describe('cursorAcpRemoteLauncher', () => { await runPromise; }); - it('applies ACP default model when setModel is cleared', async () => { + it('does not claim live Auto when ACP has no auto option', async () => { const queue = new MessageQueue2((mode) => mode.permissionMode); const client = { rpcHandlerManager: { registerHandler: vi.fn() }, @@ -1575,16 +1608,14 @@ describe('cursorAcpRemoteLauncher', () => { }); harness.setConfigOptionCalls.length = 0; - session.setModel(null); + session.setModel('auto'); await vi.waitFor(() => { - expect( - harness.setConfigOptionCalls.some( - (call) => call.configId === 'model-opt' && call.value === 'default[]' - ) - ).toBe(true); - expect(session.model).toBeUndefined(); + expect(session.model).toBe('composer-2.5[fast=false]'); }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(session.model).toBe('composer-2.5[fast=false]'); + expect(harness.setConfigOptionCalls.filter((call) => call.configId === 'model-opt')).toEqual([]); queue.close(); await runPromise; @@ -1634,7 +1665,7 @@ describe('cursorAcpRemoteLauncher', () => { await runPromise; }); - it('applyModelConfig(null) resets ACP to the default model option', async () => { + it('rejects live applyModelConfig Auto when ACP has no auto option', async () => { const queue = new MessageQueue2((mode) => mode.permissionMode); const client = { rpcHandlerManager: { registerHandler: vi.fn() }, @@ -1669,11 +1700,59 @@ describe('cursorAcpRemoteLauncher', () => { await session.applyModelConfig('composer-2.5[fast=false]'); harness.setConfigOptionCalls.length = 0; - await session.applyModelConfig(null); + await expect(session.applyModelConfig('auto')).rejects.toThrow( + 'Cursor Auto requires restarting with --model auto' + ); + expect(session.model).toBe('composer-2.5[fast=false]'); + expect(harness.setConfigOptionCalls.filter((call) => call.configId === 'model-opt')).toEqual([]); + + queue.close(); + await runPromise; + }); + + it('applies Auto via ACP when the catalog advertises auto', async () => { + harness.modelOptionValues = ['auto', 'composer-2.5[fast=false]']; + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitSessionReady: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + await session.applyModelConfig('composer-2.5[fast=false]'); + harness.setConfigOptionCalls.length = 0; + + await session.applyModelConfig('auto'); + + expect(session.model).toBe('auto'); expect( harness.setConfigOptionCalls.some( - (call) => call.configId === 'model-opt' && call.value === 'default[]' + (call) => call.configId === 'model-opt' && call.value === 'auto' ) ).toBe(true); @@ -1681,6 +1760,127 @@ describe('cursorAcpRemoteLauncher', () => { await runPromise; }); + it('rejects applyModelConfig Auto when advertised auto cannot be set', async () => { + harness.modelOptionValues = ['auto', 'composer-2.5[fast=false]']; + harness.failSetConfigOption = true; + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitSessionReady: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + + await expect(session.applyModelConfig('auto')).rejects.toThrow( + 'Cursor auto model is not available via ACP' + ); + expect(session.model).not.toBe('auto'); + + queue.close(); + await runPromise; + }); + + 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) => { + const queue = new MessageQueue2((mode) => mode.permissionMode); + const client = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitSessionReady: vi.fn() + } as unknown as ApiSessionClient; + + const session = new CursorSession({ + api: {} as never, + client, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: null, + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote', + permissionMode: 'default', + model: 'auto' + }); + session.onSessionFoundWithProtocol = vi.fn(); + queue.push('hold-open', { permissionMode: 'default' }); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.newSessionCalled).toBe(true)); + await vi.waitFor(() => expect(session.canApplyModelConfig()).toBe(true)); + expect(harness.backendArgs).toEqual({ command: 'agent', args: ['--model', 'auto', 'acp'] }); + + if (partial) { + harness.modelOptionValues = ['default', 'composer-2.5']; + harness.failFastConfigOption = true; + } + if (concurrent) { + harness.deferSetConfigOption = new Promise((resolve) => { + harness.releaseSetConfigOption = resolve; + }); + } + const concreteRequest = session.applyModelConfig('composer-2.5[fast=false]'); + const autoRequest = concurrent + ? session.applyModelConfig('auto').then( + value => ({ value, error: null }), + error => ({ value: null, error }) + ) + : null; + if (concurrent) { + await new Promise(resolve => setImmediate(resolve)); + harness.releaseSetConfigOption?.(); + } + if (partial) { + await expect(concreteRequest).rejects.toThrow( + 'Cursor model is not available via ACP' + ); + } else { + await concreteRequest; + } + if (autoRequest) { + expect((await autoRequest).error?.message).toContain('Cursor Auto requires restarting with --model auto'); + } + const concreteModel = partial ? 'composer-2.5' : 'composer-2.5[fast=false]'; + expect(session.model).toBe(concreteModel); + harness.setConfigOptionCalls.length = 0; + + await expect(session.applyModelConfig('auto')).rejects.toThrow( + 'Cursor Auto requires restarting with --model auto' + ); + expect(session.model).toBe(concreteModel); + expect(harness.setConfigOptionCalls.filter((call) => call.configId === 'model-opt')).toEqual([]); + + queue.close(); + await runPromise; + }); + it('rejects applyModelConfig when ACP does not expose the requested model', async () => { const queue = new MessageQueue2((mode) => mode.permissionMode); const client = { diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 5130a17c..8ac1c978 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -1,6 +1,7 @@ import React from 'react'; import { randomUUID } from 'node:crypto'; import { logger } from '@/ui/logger'; +import { AsyncLock } from '@/utils/lock'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; import { convertAgentMessage } from '@/agent/messageConverter'; import { PermissionAdapter } from '@/agent/permissionAdapter'; @@ -28,6 +29,7 @@ import { resolveCursorModeAfterPlanApproval, wireIdForCursorSessionState } from './utils/cursorModeConfig'; +import { CURSOR_AUTO_MODEL_ID, cursorSpawnModelId, isCursorAutoModelId } from '@hapi/protocol'; import { CURSOR_PLAN_CONTINUE } from './utils/cursorPlanContinue'; import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels'; @@ -66,7 +68,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private currentBackendModel: string | null = null; private defaultBackendModel: string | null = null; private unregisterModelApplyHandler: (() => void) | null = null; - private modelApplySeq = 0; + private readonly modelApplyLock = new AsyncLock(); private activePromptModeHash: string | null = null; /** True while a backend.prompt turn is in flight. */ private promptInFlight = false; @@ -74,6 +76,8 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private softSteerWaiters: Promise[] = []; /** True when ACP process was spawned with `--auto-review`. */ private spawnedWithAutoReview = false; + /** True when this ACP process was spawned with `--model auto`. */ + private spawnedWithCliAuto = false; /** Avoid re-queueing `/auto-review` on every mid-session mode sync. */ private autoReviewSlashQueued = false; private cursorMcpOverlay: CursorMcpOverlayHandle | null = null; @@ -368,6 +372,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { throw new Error('Failed to establish Cursor ACP session'); } this.acpSessionId = acpSessionId; + this.spawnedWithCliAuto = cursorSpawnModelId(spawnModel) === CURSOR_AUTO_MODEL_ID; if (acpSessionId !== resumeSessionId) { session.onSessionFoundWithProtocol(acpSessionId, 'acp'); @@ -410,7 +415,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { optimistic: false, throwOnFailure: mustRestoreDesiredModel }); - } else if (this.currentBackendModel && !isSpawnDefaultModel(this.currentBackendModel)) { + } else if (this.currentBackendModel && !isCursorAutoModelId(this.currentBackendModel)) { this.pushModelStatusLine(this.currentBackendModel); } @@ -944,34 +949,54 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { model: string | null | undefined, previousSetModel: CursorSession['setModel'], options: { optimistic: boolean; throwOnFailure: boolean } + ): Promise { + return this.modelApplyLock.inLock(() => + this.applyLiveModelLocked(backend, acpSessionId, model, previousSetModel, options)); + } + + private async applyLiveModelLocked( + backend: AcpSdkBackend, + acpSessionId: string, + model: string | null | undefined, + previousSetModel: CursorSession['setModel'], + options: { optimistic: boolean; throwOnFailure: boolean } ): Promise { const requested = model?.trim(); const previousModel = this.currentBackendModel ?? this.session.model ?? null; - const applySeq = ++this.modelApplySeq; - if (!requested || isSpawnDefaultModel(requested)) { + if (!requested || isCursorAutoModelId(requested)) { const modelOption = backend.getConfigOptionByCategory?.(acpSessionId, 'model'); - const defaultWire = modelOption?.options?.find( - (option) => isSpawnDefaultModel(option.value) + const autoWire = modelOption?.options?.find( + (option) => option.value.trim().toLowerCase() === CURSOR_AUTO_MODEL_ID )?.value; - if (modelOption && defaultWire && backend.setConfigOption) { + let appliedLive = false; + if (modelOption && autoWire && backend.setConfigOption) { try { - await backend.setConfigOption(acpSessionId, modelOption.id, defaultWire); - backend.pinSessionModelWireId(acpSessionId, defaultWire); + await backend.setConfigOption(acpSessionId, modelOption.id, autoWire); + backend.pinSessionModelWireId(acpSessionId, autoWire); + appliedLive = true; } catch (error) { - logger.debug('[cursor-acp] Failed to set default model via ACP', error); + logger.debug('[cursor-acp] Failed to set auto model via ACP', error); if (options.throwOnFailure) { - throw new Error('Cursor default model is not available via ACP'); + throw new Error('Cursor auto model is not available via ACP'); } } - } else if (options.throwOnFailure) { - throw new Error('Cursor default model is not available via ACP'); } - this.currentBackendModel = null; - previousSetModel(undefined); + // Live ACP catalogs usually advertise `default[]`, which is not CLI + // Auto (#1817). Only acknowledge Auto after a confirmed spawn or ACP + // `auto` option. In-session Auto without either requires a restart. + if (!appliedLive && !this.spawnedWithCliAuto) { + if (options.throwOnFailure) { + throw new Error('Cursor Auto requires restarting with --model auto'); + } + return previousModel; + } + previousSetModel(CURSOR_AUTO_MODEL_ID); + this.currentBackendModel = CURSOR_AUTO_MODEL_ID; + this.pushModelStatusLine(CURSOR_AUTO_MODEL_ID); this.session.pushKeepAlive(); syncCursorModelsFromAcp(backend, acpSessionId); - return null; + return CURSOR_AUTO_MODEL_ID; } if (options.optimistic) { @@ -982,15 +1007,23 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } const result = await applyCursorAcpModel(backend, acpSessionId, requested); + if (result.applied || result.partiallyAppliedWireId) { + this.spawnedWithCliAuto = false; + } if (!result.applied || !result.resolvedWireId) { const message = `Cursor model is not available via ACP: ${requested}`; logger.warn(`[cursor-acp] ${message}`); - if (options.optimistic && applySeq === this.modelApplySeq) { + if (result.partiallyAppliedWireId) { + this.currentBackendModel = result.partiallyAppliedWireId; + previousSetModel(result.partiallyAppliedWireId); + this.pushModelStatusLine(result.partiallyAppliedWireId); + this.session.pushKeepAlive(); + } else if (options.optimistic) { this.currentBackendModel = previousModel; previousSetModel(previousModel ?? undefined); this.session.pushKeepAlive(); - } else if (!options.throwOnFailure && previousModel && !isSpawnDefaultModel(previousModel)) { + } else if (!options.throwOnFailure && previousModel && !isCursorAutoModelId(previousModel)) { this.currentBackendModel = previousModel; previousSetModel(previousModel); this.session.pushKeepAlive(); @@ -1000,7 +1033,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { if (options.throwOnFailure) { throw new Error(message); } - return previousModel; + return result.partiallyAppliedWireId ?? previousModel; } const sessionWire = wireIdForCursorSessionState( @@ -1008,10 +1041,6 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { result.resolvedWireId ); - if (applySeq !== this.modelApplySeq) { - return this.currentBackendModel; - } - const changed = sessionWire !== this.currentBackendModel || this.session.model !== sessionWire; this.currentBackendModel = sessionWire; previousSetModel(sessionWire); @@ -1025,7 +1054,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private pushModelStatusLine(model: string | null | undefined): void { const trimmed = model?.trim(); - if (!trimmed || isSpawnDefaultModel(trimmed)) { + if (!trimmed || isCursorAutoModelId(trimmed)) { this.messageBuffer.addMessage('[MODEL:auto]', 'system'); return; } @@ -1236,11 +1265,6 @@ function formatAcpLoadError(error: unknown): Record { return { message: String(error) }; } -function isSpawnDefaultModel(modelId: string): boolean { - const normalized = modelId.trim().toLowerCase(); - return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; -} - function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string): void { const snapshot = buildCursorModelsSnapshotFromAcp(backend, acpSessionId); if (!snapshot) { diff --git a/cli/src/cursor/utils/cursorAcpBackend.test.ts b/cli/src/cursor/utils/cursorAcpBackend.test.ts index 43cfe308..03c8b064 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.test.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.test.ts @@ -30,8 +30,14 @@ describe('createCursorAcpBackend', () => { ]); }); - it('omits --model for default/auto spawn selection', () => { - expect(buildCursorAcpArgs({ model: 'auto' })).toEqual(['acp']); + it('pins CLI auto with --model auto', () => { + expect(buildCursorAcpArgs({ model: 'auto' })).toEqual(['--model', 'auto', 'acp']); + expect(buildCursorAcpArgs({ model: 'default[]' })).toEqual(['--model', 'auto', 'acp']); + }); + + it('omits --model when spawn model is unset', () => { + expect(buildCursorAcpArgs({ model: null })).toEqual(['acp']); + expect(buildCursorAcpArgs({})).toEqual(['acp']); }); it('adds --auto-review, --worktree, and --add-dir before acp', () => { diff --git a/cli/src/cursor/utils/cursorAcpBackend.ts b/cli/src/cursor/utils/cursorAcpBackend.ts index a1c3ea9e..e3955bba 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.ts @@ -1,5 +1,6 @@ import { basename, join } from 'node:path'; import { homedir } from 'node:os'; +import { cursorSpawnModelId } from '@hapi/protocol'; import { AcpSdkBackend } from '@/agent/backends/acp'; import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; @@ -13,12 +14,6 @@ function filterEnv(env: NodeJS.ProcessEnv): Record { return result; } -function isDefaultSpawnModel(model: string | null | undefined): boolean { - if (!model) return true; - const normalized = model.trim().toLowerCase(); - return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; -} - export type CursorAcpBackendOptions = { cwd: string; model?: string | null; @@ -58,8 +53,9 @@ export function buildCursorAcpArgs(opts: Omit): } } - if (!isDefaultSpawnModel(opts.model)) { - args.push('--model', opts.model!.trim()); + const spawnModel = cursorSpawnModelId(opts.model); + if (spawnModel) { + args.push('--model', spawnModel); } args.push('acp'); diff --git a/cli/src/cursor/utils/cursorAcpModelsBridge.ts b/cli/src/cursor/utils/cursorAcpModelsBridge.ts index 89efbc42..6703731c 100644 --- a/cli/src/cursor/utils/cursorAcpModelsBridge.ts +++ b/cli/src/cursor/utils/cursorAcpModelsBridge.ts @@ -3,6 +3,8 @@ import type { CursorModelSummary } from '@hapi/protocol/apiTypes'; export type CursorModelsSnapshot = { availableModels: CursorModelSummary[]; currentModelId: string | null; + /** ACP advertised the parameterized picker (bare bases + fast/thought_level options). */ + parameterized?: boolean; }; let snapshot: CursorModelsSnapshot | null = null; diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts index 8b4903c3..860a0689 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts @@ -48,7 +48,7 @@ describe('buildCursorModelsSnapshotFromAcp', () => { expect(snapshot?.availableModels).toHaveLength(2); }); - it('synthesizes Composer fast variants from parameterized model + fast config options', () => { + it('keeps parameterized model + fast options as bare bases without synthesizing wires', () => { const backend = { getSessionModelsMetadata: () => ({ availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], @@ -87,10 +87,10 @@ describe('buildCursorModelsSnapshotFromAcp', () => { const snapshot = buildCursorModelsSnapshotFromAcp(backend, 's1'); - expect(snapshot?.availableModels.map((entry) => entry.modelId).sort()).toEqual([ - 'composer-2.5[fast=false]', - 'composer-2.5[fast=true]' - ]); - expect(snapshot?.currentModelId).toBe('composer-2.5[fast=false]'); + // `composer-2.5[fast=…]` is rejected by Cursor's `--model` whenever the wire + // omits another parameter of the model, so only the bare base is advertised. + expect(snapshot?.availableModels.map((entry) => entry.modelId)).toEqual(['composer-2.5']); + expect(snapshot?.currentModelId).toBe('composer-2.5'); + expect(snapshot?.parameterized).toBe(true); }); }); diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts index 5c3b27e2..09446899 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts @@ -4,6 +4,8 @@ import type { CursorModelSummary } from '@hapi/protocol/apiTypes'; export type CursorModelsSnapshot = { availableModels: CursorModelSummary[]; currentModelId: string | null; + /** ACP advertised the parameterized picker (bare bases + fast/thought_level options). */ + parameterized?: boolean; }; type CursorAcpModelSnapshotBackend = Pick @@ -39,8 +41,16 @@ function mergeModelEntries( } /** - * Zed-style Cursor catalog: `configOptions` model category lists every wire id; + * Zed-style Cursor catalog: `configOptions` model category lists every model id; * `availableModels` alone is often one variant per base family. + * + * Cursor's parameterized picker advertises bare model bases plus separate + * `fast` / `thought_level` config options. Cursor rejects every bracket id whose + * parameter set is not that model's complete set, and those per-model sets are not + * derivable from the options, so no wire ids are ever synthesized here: the + * advertised values are kept verbatim (bare bases are accepted by `--model`, with + * defaults applied) and the requested parameters are applied over ACP config + * options by `applyParameterizedCursorModel`. */ export function buildCursorModelsSnapshotFromAcp( backend: CursorAcpModelSnapshotBackend, @@ -48,38 +58,14 @@ export function buildCursorModelsSnapshotFromAcp( ): CursorModelsSnapshot | null { const metadata = backend.getSessionModelsMetadata(sessionId); const modelOption = findConfigOption(backend, sessionId, 'model'); - const fastOption = findConfigOption(backend, sessionId, 'fast'); if (!metadata && !modelOption) { return null; } const merged = new Map(); - const parameterizedFastModels: CursorModelSummary[] = []; - if (modelOption?.options?.length && fastOption?.options?.length) { - const fastValues = fastOption.options - .map((option) => option.value.trim()) - .filter((value) => value === 'false' || value === 'true'); - if (fastValues.length > 0) { - for (const option of modelOption.options) { - const modelId = option.value.trim(); - if (!modelId || modelId.includes('[')) { - continue; - } - for (const fast of fastValues) { - parameterizedFastModels.push({ - modelId: `${modelId}[fast=${fast}]`, - name: option.name - }); - } - } - } - } - - if (parameterizedFastModels.length > 0) { - mergeModelEntries(merged, parameterizedFastModels); - } else if (modelOption?.options?.length) { + if (modelOption?.options?.length) { mergeModelEntries(merged, modelOption.options.map((option) => ({ modelId: option.value, name: option.name @@ -87,26 +73,24 @@ export function buildCursorModelsSnapshotFromAcp( } if (metadata?.availableModels?.length) { - mergeModelEntries( - merged, - parameterizedFastModels.length > 0 - ? metadata.availableModels.filter((entry) => entry.modelId.includes('[')) - : metadata.availableModels - ); + mergeModelEntries(merged, metadata.availableModels); } if (merged.size === 0) { return null; } - const currentModelId = parameterizedFastModels.length > 0 && modelOption?.currentValue && fastOption?.currentValue - ? `${modelOption.currentValue}[fast=${fastOption.currentValue}]` - : metadata?.currentModelId - ?? modelOption?.currentValue - ?? null; + const parameterized = Boolean( + modelOption?.options?.length + && modelOption.options.every((option) => { + const value = option.value.trim(); + return value.length > 0 && !value.includes('['); + }) + ); return { availableModels: [...merged.values()], - currentModelId + currentModelId: metadata?.currentModelId ?? modelOption?.currentValue ?? null, + ...(parameterized ? { parameterized: true } : {}) }; } diff --git a/cli/src/cursor/utils/cursorModeConfig.test.ts b/cli/src/cursor/utils/cursorModeConfig.test.ts index fbc987b3..0fd59235 100644 --- a/cli/src/cursor/utils/cursorModeConfig.test.ts +++ b/cli/src/cursor/utils/cursorModeConfig.test.ts @@ -330,6 +330,221 @@ describe('applyCursorAcpModel', () => { expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'true'); }); + it('applies the requested effort over config options for variant sku requests (#1818)', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'claude-opus-4-8', name: 'Claude Opus 4.8' }], + currentModelId: 'claude-opus-4-8' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { id: 'model', category: 'model', options: [{ value: 'claude-opus-4-8' }] }; + } + if (category === 'fast') { + return { id: 'fast', category: 'fast', options: [{ value: 'false' }, { value: 'true' }] }; + } + return undefined; + }), + getSessionConfigOptions: vi.fn(() => [ + { id: 'model', category: 'model', options: [{ value: 'claude-opus-4-8' }] }, + { + id: 'effort', + category: 'thought_level', + currentValue: 'high', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }] + }, + { id: 'fast', category: 'model_config', options: [{ value: 'false' }, { value: 'true' }] } + ]) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'claude-opus-4-8-low')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'claude-opus-4-8[fast=false,effort=low]', + requestedWireId: 'claude-opus-4-8-low' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'claude-opus-4-8'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'false'); + expect(setConfigOption).toHaveBeenNthCalledWith(3, 's1', 'effort', 'low'); + }); + + it('skips parameter axes the selected model does not expose (#1818)', async () => { + let selected = 'grok-4.6'; + const fastOption = { id: 'fast', category: 'fast', options: [{ value: 'false' }, { value: 'true' }] }; + const modelOption = { id: 'model', category: 'model', options: [{ value: 'gemini-3-flash' }] }; + const setConfigOption = vi.fn(async (_sessionId: string, id: string, value: string) => { + if (id === 'model') { + selected = value; + } + }); + // Cursor drops fast/thought_level for a model that supports neither. + const activeOptions = () => (selected === 'gemini-3-flash' ? [modelOption] : [modelOption, fastOption]); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'gemini-3-flash' }], + currentModelId: 'gemini-3-flash' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => + activeOptions().find((option) => option.category === category) + ), + getSessionConfigOptions: vi.fn(() => activeOptions()) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'gemini-3-flash')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'gemini-3-flash' + }); + expect(setConfigOption).toHaveBeenCalledTimes(1); + expect(setConfigOption).toHaveBeenCalledWith('s1', 'model', 'gemini-3-flash'); + }); + + it('maps aliased effort values onto the per-model option id (#1818)', async () => { + const modelOption = { id: 'model', category: 'model', options: [{ value: 'gpt-5.5' }] }; + const backend = mockModelBackend({ + setConfigOption: vi.fn(async () => {}), + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'gpt-5.5' }], + currentModelId: 'gpt-5.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return modelOption; + } + // gpt-5.5 spells the top effort `extra-high` on the `reasoning` id. + if (category === 'reasoning') { + return { + id: 'reasoning', + category: 'thought_level', + currentValue: 'medium', + options: [{ value: 'none' }, { value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'extra-high' }] + }; + } + return undefined; + }), + getSessionConfigOptions: vi.fn(() => [modelOption, { id: 'reasoning', category: 'thought_level', options: [{ value: 'extra-high' }] }]) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'gpt-5.5-xhigh')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'gpt-5.5[reasoning=extra-high]' + }); + expect(backend.setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'gpt-5.5'); + expect(backend.setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'reasoning', 'extra-high'); + }); + + it('leaves Claude thinking untouched while switching effort (#1818)', async () => { + const setConfigOption = vi.fn(async () => {}); + const options = [ + { id: 'model', category: 'model', options: [{ value: 'claude-opus-4-8' }] }, + { id: 'thinking', category: 'thought_level', currentValue: 'true', options: [{ value: 'false' }, { value: 'true' }] }, + { id: 'effort', category: 'thought_level', currentValue: 'high', options: [{ value: 'low' }, { value: 'high' }, { value: 'xhigh' }] } + ]; + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ availableModels: [{ modelId: 'claude-opus-4-8' }], currentModelId: 'claude-opus-4-8' })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return options[0]; + } + // Both `thinking` and `effort` are thought_level: category lookup must not + // pick the toggle when the requested axis is effort. + return category === 'effort' ? options[2] : undefined; + }), + getSessionConfigOptions: vi.fn(() => options) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'claude-opus-4-8-xhigh')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'claude-opus-4-8[effort=xhigh]' + }); + expect(setConfigOption).toHaveBeenCalledTimes(2); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'effort', 'xhigh'); + }); + + it('applies effort on gemini models through the reasoning_effort option id (#1818)', async () => { + const options = [ + { id: 'model', category: 'model', options: [{ value: 'gemini-3.8-flash' }] }, + { id: 'reasoning_effort', category: 'thought_level', currentValue: 'high', options: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }] } + ]; + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ availableModels: [{ modelId: 'gemini-3.8-flash' }], currentModelId: 'gemini-3.8-flash' })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => + category === 'model' ? options[0] : undefined + ), + getSessionConfigOptions: vi.fn(() => options) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'gemini-3.8-flash-high')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'gemini-3.8-flash[reasoning_effort=high]' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'reasoning_effort', 'high'); + }); + + it('resolves the advertised base for a legacy cursor- prefixed sku (#1818)', async () => { + const setConfigOption = vi.fn(async () => {}); + const options = [ + { id: 'model', category: 'model', options: [{ value: 'grok-4.6' }] }, + { id: 'effort', category: 'thought_level', currentValue: 'high', options: [{ value: 'low' }, { value: 'high' }] } + ]; + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ availableModels: [{ modelId: 'grok-4.6' }], currentModelId: 'grok-4.6' })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => + category === 'model' ? options[0] : undefined + ), + getSessionConfigOptions: vi.fn(() => options) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'cursor-grok-4.6-high')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'grok-4.6[effort=high]', + requestedWireId: 'cursor-grok-4.6-high' + }); + // The `cursor-` family prefix must map onto the advertised `grok-4.6` base. + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'grok-4.6'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'effort', 'high'); + }); + + it('reads the new model effort option after the base switch (#1818)', async () => { + // Cursor renames the effort axis across families: gpt-5.5 uses `reasoning`, + // claude-opus-4-8 uses `effort`. The apply path must resolve against the + // options advertised for the model it just selected, not the previous ones. + let selected = 'gpt-5.5'; + const modelOption = { id: 'model', category: 'model', options: [{ value: 'gpt-5.5' }, { value: 'claude-opus-4-8' }] }; + const gptOptions = [modelOption, { id: 'reasoning', category: 'thought_level', currentValue: 'medium', options: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }] }]; + const claudeOptions = [modelOption, { id: 'effort', category: 'thought_level', currentValue: 'high', options: [{ value: 'low' }, { value: 'high' }, { value: 'xhigh' }] }]; + const activeOptions = () => (selected === 'gpt-5.5' ? gptOptions : claudeOptions); + const setConfigOption = vi.fn(async (_sessionId: string, id: string, value: string) => { + if (id === 'model') { + selected = value; + } + }); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'claude-opus-4-8' }], + currentModelId: 'claude-opus-4-8' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => + activeOptions().find((option) => option.category === category) + ), + getSessionConfigOptions: vi.fn(() => activeOptions()) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'claude-opus-4-8-xhigh')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'claude-opus-4-8[effort=xhigh]' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'claude-opus-4-8'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'effort', 'xhigh'); + expect(setConfigOption).not.toHaveBeenCalledWith('s1', 'reasoning', expect.anything()); + }); + it('does not fall back to base-only model apply when parameterized fast update fails', async () => { const setConfigOption = vi.fn() .mockResolvedValueOnce(undefined) @@ -360,10 +575,11 @@ describe('applyCursorAcpModel', () => { }); await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({ - applied: false + applied: false, + partiallyAppliedWireId: 'composer-2.5' }); expect(setConfigOption).toHaveBeenCalledTimes(2); - expect(backend.pinSessionModelWireId).not.toHaveBeenCalled(); + expect(backend.pinSessionModelWireId).toHaveBeenCalledWith('s1', 'composer-2.5'); }); it('retries set_config_option once before failing apply', async () => { diff --git a/cli/src/cursor/utils/cursorModeConfig.ts b/cli/src/cursor/utils/cursorModeConfig.ts index 83389071..72dd00b4 100644 --- a/cli/src/cursor/utils/cursorModeConfig.ts +++ b/cli/src/cursor/utils/cursorModeConfig.ts @@ -1,15 +1,10 @@ import type { CursorPermissionMode } from '@hapi/protocol/types'; -import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId, resolveCursorLegacyModelBase } from '@hapi/protocol'; +import { cursorCliSkuBaseId, cursorModelBaseId, cursorModelBaseMatches, isCursorAutoModelId, matchCliSkuToAcpWireId, parseCursorSkuParamHints, parseCursorWireParams, resolveCursorLegacyModelBase } from '@hapi/protocol'; import type { AcpSdkBackend } from '@/agent/backends/acp'; import { logger } from '@/ui/logger'; export type CursorAcpMode = 'agent' | 'plan' | 'ask' | 'debug'; -function isDefaultCursorModelId(modelId: string): boolean { - const normalized = modelId.trim().toLowerCase(); - return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; -} - export function toCursorAcpMode(mode: CursorPermissionMode | undefined): CursorAcpMode { if (mode === 'plan') return 'plan'; if (mode === 'ask') return 'ask'; @@ -85,6 +80,7 @@ export async function applyCursorAcpMode( export type ApplyCursorAcpModelResult = { applied: boolean; + partiallyAppliedWireId?: string; /** Wire id applied via ACP when switching succeeds */ resolvedWireId?: string; /** Original hub/UI request before catalog resolution */ @@ -164,10 +160,69 @@ function fastHintForCursorSkuOrWire(modelId: string): 'false' | 'true' { return lower.includes('-fast') ? 'true' : 'false'; } -function cursorRequestBaseId(modelId: string): string { - return modelId.includes('[') +function cursorRequestBaseId(modelId: string, modelOption?: ConfigOption): string { + const raw = modelId.includes('[') ? cursorModelBaseId(modelId) : cursorCliSkuBaseId(modelId); + // CLI skus may carry the legacy `cursor-` family prefix (cursor-grok-4.6-high). + const advertised = modelOption?.options?.find((option) => cursorModelBaseMatches(option.value, raw)); + return advertised?.value ?? raw; +} + +/** CLI sku / ACP wire effort values → Cursor config option values (aliases included). */ +const EFFORT_OPTION_VALUES: Readonly> = { + none: ['none'], + low: ['low'], + medium: ['medium'], + high: ['high'], + xhigh: ['xhigh', 'extra-high'], + 'extra-high': ['extra-high', 'xhigh'], + max: ['max'] +}; + +/** Cursor exposes the effort axis under different ids per model family. */ +const EFFORT_OPTION_IDS = ['effort', 'reasoning', 'reasoning_effort'] as const; + +function effortHintForCursorSkuOrWire(modelId: string): string | null { + const params = modelId.includes('[') + ? parseCursorWireParams(modelId) + : parseCursorSkuParamHints(modelId); + const hint = params.effort ?? params.reasoning ?? params.reasoning_effort; + return hint?.trim() || null; +} + +function resolveOptionValue(option: ConfigOption | undefined, candidates: readonly string[]): string | null { + if (!option?.options?.length) { + return null; + } + for (const candidate of candidates) { + if (option.options.some((entry) => entry.value === candidate)) { + return candidate; + } + } + return null; +} + +/** + * Effort config option for the model that is currently selected. Ids are tried first + * because `thought_level` also covers Claude's separate `thinking` toggle. + */ +function findEffortConfigOption( + backend: AcpSdkBackend, + sessionId: string, + hint: string +): { option: ConfigOption; value: string } | null { + const candidates = EFFORT_OPTION_VALUES[hint] ?? [hint]; + for (const id of EFFORT_OPTION_IDS) { + const option = findConfigOption(backend, sessionId, id); + const value = resolveOptionValue(option, candidates); + if (option && value) { + return { option, value }; + } + } + const thoughtLevel = backend.getConfigOptionByCategory?.(sessionId, 'thought_level'); + const value = resolveOptionValue(thoughtLevel, candidates); + return thoughtLevel && value ? { option: thoughtLevel, value } : null; } async function applyParameterizedCursorModel( @@ -176,28 +231,57 @@ async function applyParameterizedCursorModel( requested: string ): Promise { const modelOption = findConfigOption(backend, sessionId, 'model'); - const fastOption = findConfigOption(backend, sessionId, 'fast'); - if (!modelOption || !fastOption || !backend.setConfigOption) { + if (!modelOption || !backend.setConfigOption) { return 'unsupported'; } - const baseModel = cursorRequestBaseId(requested); + const baseModel = cursorRequestBaseId(requested, modelOption); const fast = fastHintForCursorSkuOrWire(requested); - if (!optionHasValue(modelOption, baseModel) || !optionHasValue(fastOption, fast)) { + // Parameterized sessions advertise bare bases. A model option holding complete + // wires belongs to the non-parameterized path, which applies the wire as-is. + if (!optionHasValue(modelOption, baseModel)) { return 'unsupported'; } + let partiallyAppliedWireId: string | undefined; try { await backend.setConfigOption(sessionId, modelOption.id, baseModel); - await backend.setConfigOption(sessionId, fastOption.id, fast); + partiallyAppliedWireId = baseModel; + + // Selecting the base refreshes Cursor's per-model config options. Re-read them so + // the parameters are applied against the model that is now active, and skipped + // for models that do not expose that axis. + const appliedParams: string[] = []; + const activeFast = findConfigOption(backend, sessionId, 'fast'); + if (activeFast && optionHasValue(activeFast, fast)) { + await backend.setConfigOption(sessionId, activeFast.id, fast); + appliedParams.push(`fast=${fast}`); + partiallyAppliedWireId = `${baseModel}[${appliedParams.join(',')}]`; + } + + const effortHint = effortHintForCursorSkuOrWire(requested); + const effort = effortHint ? findEffortConfigOption(backend, sessionId, effortHint) : null; + if (effort) { + await backend.setConfigOption(sessionId, effort.option.id, effort.value); + appliedParams.push(`${effort.option.id}=${effort.value}`); + partiallyAppliedWireId = `${baseModel}[${appliedParams.join(',')}]`; + } + + // Only parameters that were actually applied belong in the pinned wire; a model + // that exposes neither axis stays on its bare base. + const resolved = appliedParams.length > 0 + ? `${baseModel}[${appliedParams.join(',')}]` + : baseModel; + backend.pinSessionModelWireId(sessionId, resolved); + return { applied: true, resolvedWireId: resolved, requestedWireId: requested }; } catch (error) { logger.debug('[cursor-acp] parameterized model config failed', error); + if (partiallyAppliedWireId) { + backend.pinSessionModelWireId(sessionId, partiallyAppliedWireId); + return { applied: false, partiallyAppliedWireId }; + } return 'failed'; } - - const resolved = `${baseModel}[fast=${fast}]`; - backend.pinSessionModelWireId(sessionId, resolved); - return { applied: true, resolvedWireId: resolved, requestedWireId: requested }; } /** @@ -210,7 +294,7 @@ export async function applyCursorAcpModel( modelId: string | null | undefined ): Promise { const trimmed = modelId?.trim(); - if (!trimmed || isDefaultCursorModelId(trimmed)) { + if (!trimmed || isCursorAutoModelId(trimmed)) { return { applied: false }; } diff --git a/cli/src/cursor/utils/cursorStaleModelRemap.test.ts b/cli/src/cursor/utils/cursorStaleModelRemap.test.ts index 76194d64..561615f2 100644 --- a/cli/src/cursor/utils/cursorStaleModelRemap.test.ts +++ b/cli/src/cursor/utils/cursorStaleModelRemap.test.ts @@ -28,6 +28,11 @@ describe('cursorStaleModelRemap', () => { expect(resolveCursorSpawnModel('grok-4.5[fast=false]')).toBe('cursor-grok-4.5-medium'); }); + it('pins default aliases to CLI auto before spawn remap', () => { + expect(resolveCursorSpawnModel('auto')).toBe('auto'); + expect(resolveCursorSpawnModel('default[]')).toBe('auto'); + }); + it('remaps once from stderr Available models on model_not_found', () => { const remapped = tryRemapCursorSpawnModelFromError( 'grok-4.5[fast=true]', diff --git a/cli/src/cursor/utils/cursorStaleModelRemap.ts b/cli/src/cursor/utils/cursorStaleModelRemap.ts index 63f2521c..e60ed98b 100644 --- a/cli/src/cursor/utils/cursorStaleModelRemap.ts +++ b/cli/src/cursor/utils/cursorStaleModelRemap.ts @@ -1,15 +1,13 @@ import type { CursorModelsResponse } from '@hapi/protocol/apiTypes'; import { + CURSOR_AUTO_MODEL_ID, + cursorSpawnModelId, + isCursorAutoModelId, parseCursorAvailableModelsFromRejection, remapStaleCursorModelId } from '@hapi/protocol'; import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; -function isDefaultSpawnModel(modelId: string): boolean { - const normalized = modelId.trim().toLowerCase(); - return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; -} - export function catalogEntriesFromCursorModelsResponse( response: CursorModelsResponse | null | undefined ): { modelId: string }[] { @@ -36,10 +34,14 @@ export function catalogEntriesFromCursorModelsResponse( export function resolveCursorSpawnModel( model: string | null | undefined ): string | null | undefined { - const trimmed = model?.trim(); - if (!trimmed || isDefaultSpawnModel(trimmed)) { + const pinned = cursorSpawnModelId(model); + if (!pinned) { return model; } + if (pinned === CURSOR_AUTO_MODEL_ID) { + return CURSOR_AUTO_MODEL_ID; + } + const trimmed = pinned; const cached = catalogEntriesFromCursorModelsResponse(readSharedCursorModelsCache()); if (cached.length === 0) { @@ -54,7 +56,7 @@ export function tryRemapCursorSpawnModelFromError( ...sources: Array ): string | null { const trimmed = model?.trim(); - if (!trimmed || isDefaultSpawnModel(trimmed)) { + if (!trimmed || isCursorAutoModelId(trimmed)) { return null; } diff --git a/cli/src/modules/common/cursorAcpModelProbe.ts b/cli/src/modules/common/cursorAcpModelProbe.ts index b694cac7..214f6459 100644 --- a/cli/src/modules/common/cursorAcpModelProbe.ts +++ b/cli/src/modules/common/cursorAcpModelProbe.ts @@ -30,7 +30,8 @@ export async function runCursorAcpModelProbe(cwd?: string): Promise { expect(spawnMock).not.toHaveBeenCalled() }) + test('attaches variant cliModelSkus to parameterized bare base catalogs (#1818)', async () => { + vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) + const bareCatalog = [ + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + { modelId: 'claude-opus-4-8', name: 'Claude Opus 4.8' }, + { modelId: 'grok-4.6', name: 'Cursor Grok 4.6' } + ] + setCursorAcpModelsSnapshot({ + availableModels: bareCatalog, + currentModelId: 'composer-2.5', + parameterized: true + }) + writeSharedCursorModelsCache({ + success: true, + parameterized: true, + availableModels: bareCatalog, + currentModelId: 'composer-2.5', + cliModelSkus: [ + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + { modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' }, + { modelId: 'claude-opus-4-8-low', name: 'Claude Opus 4.8 Low' }, + { modelId: 'cursor-grok-4.6-high', name: 'Cursor Grok 4.6' } + ] + }) + + const result = await listCursorModels() + + expect(result.availableModels?.map((row) => row.modelId)).toEqual([ + 'composer-2.5', + 'claude-opus-4-8', + 'grok-4.6' + ]) + // Parameterized ACP sessions apply fast/effort over config options, so the + // spawn-safe variant skus stay attached to their bare base rows — including + // CLI skus that keep Cursor's legacy `cursor-` family prefix. + expect(result.cliModelSkus?.map((row) => row.modelId)).toEqual([ + 'composer-2.5', + 'composer-2.5-fast', + 'claude-opus-4-8-low', + 'cursor-grok-4.6-high' + ]) + expect(spawnMock).not.toHaveBeenCalled() + }) + test('enriches live ACP snapshot with fuller shared cliModelSkus while lock is active', async () => { vi.mocked(isAgentAcpTransportActive).mockReturnValue(true) setCursorAcpModelsSnapshot({ diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts index 33a17c5e..5da61a34 100644 --- a/cli/src/modules/common/cursorModels.ts +++ b/cli/src/modules/common/cursorModels.ts @@ -18,6 +18,7 @@ import { import { cursorCliSkuBaseId, cursorModelBaseId, + cursorModelBaseMatches, isCursorAcpCatalogModelId, isCursorAcpWireModelId } from '@hapi/protocol'; @@ -31,6 +32,7 @@ export function buildCursorModelsSeedPayload( availableModels: CursorModelSummary[]; currentModelId: string | null; cliModelSkus?: readonly CursorModelSummary[]; + parameterized?: boolean; }, shared?: CursorModelsResponse | null ): ListCursorModelsResponse { @@ -42,6 +44,7 @@ export function buildCursorModelsSeedPayload( success: true, availableModels: snapshot.availableModels, currentModelId: snapshot.currentModelId, + ...(snapshot.parameterized ? { parameterized: true } : {}), ...(cliModelSkus.length > 0 ? { cliModelSkus } : {}) }; } @@ -68,9 +71,9 @@ function filterCliSkusForWireBases( cliSkus: CursorModelSummary[], wires: CursorModelSummary[] ): CursorModelSummary[] { - const wireBases = new Set( - wires.map((entry) => cursorModelBaseId(entry.modelId)).filter((base) => base.length > 0) - ); + const wireBases = wires + .map((entry) => cursorModelBaseId(entry.modelId)) + .filter((base) => base.length > 0); return cliSkus.filter((entry) => { const modelId = entry.modelId.trim(); @@ -78,7 +81,8 @@ function filterCliSkusForWireBases( if (!modelId || modelId === 'auto' || isCursorAcpWireModelId(modelId)) { return false; } - return wireBases.has(cursorCliSkuBaseId(modelId)); + const skuBase = cursorCliSkuBaseId(modelId); + return wireBases.some((base) => cursorModelBaseMatches(skuBase, base)); }); } @@ -87,22 +91,24 @@ function attachCliSkusToResponse( cliSkus: readonly CursorModelSummary[] ): ListCursorModelsResponse { const wires = (response.availableModels ?? []).filter((entry) => isCursorAcpCatalogModelId(entry.modelId)); - // Suffixed CLI variants (effort/speed) only apply when ACP exposes parameterized - // wires for that base. Bare-only catalogs cannot express those variants - // (apply path is model + fast at most), so attaching them creates dead picker rows. - const parameterizedBases = new Set( - wires + // Variant skus are only usable when the ACP apply path can express them: + // parameterized pickers apply model + fast + thought_level over config options, + // and wire catalogs already pair each variant with a base wire. Bare-only + // catalogs without the parameterized picker keep base rows only. + const applyableBases = (response.parameterized === true + ? wires.map((entry) => cursorModelBaseId(entry.modelId)) + : wires .filter((entry) => isCursorAcpWireModelId(entry.modelId)) .map((entry) => cursorModelBaseId(entry.modelId)) - .filter((base) => base.length > 0) - ); + ).filter((base) => base.length > 0); const filtered = filterCliSkusForWireBases( mergeCliModelSkus(response.cliModelSkus ?? [], [...cliSkus]), wires ).filter((entry) => { const modelId = entry.modelId.trim(); const base = cursorCliSkuBaseId(modelId); - return modelId === base || parameterizedBases.has(base); + return modelId === base + || applyableBases.some((candidate) => cursorModelBaseMatches(base, candidate)); }); if (filtered.length === 0) { return response.cliModelSkus?.length diff --git a/cli/src/modules/common/cursorModelsSharedCache.test.ts b/cli/src/modules/common/cursorModelsSharedCache.test.ts index d9394587..8ed3700a 100644 --- a/cli/src/modules/common/cursorModelsSharedCache.test.ts +++ b/cli/src/modules/common/cursorModelsSharedCache.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, test } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -59,4 +59,21 @@ describe('cursorModelsSharedCache', () => { expect(readSharedCursorModelsCache()?.cliModelSkus).toEqual(payload.cliModelSkus); }); + + test('ignores pre-versioned cache files that carry synthesized wire ids', () => { + // v1 files carried `[fast=…]` wires Cursor rejects; a runner must re-probe + // instead of serving them forever (listCursorModels trusts the cache). + mkdirSync(join(testHapiHome, 'cache'), { recursive: true }); + writeFileSync( + join(testHapiHome, 'cache', 'cursor-models.json'), + JSON.stringify({ + success: true, + availableModels: [{ modelId: 'grok-4.6[fast=false]', name: 'Cursor Grok 4.6' }], + currentModelId: 'grok-4.6[fast=false]' + }), + 'utf8' + ); + + expect(readSharedCursorModelsCache()).toBeNull(); + }); }); diff --git a/cli/src/modules/common/cursorModelsSharedCache.ts b/cli/src/modules/common/cursorModelsSharedCache.ts index b7e50849..e51e5e71 100644 --- a/cli/src/modules/common/cursorModelsSharedCache.ts +++ b/cli/src/modules/common/cursorModelsSharedCache.ts @@ -18,6 +18,18 @@ function isUsableModelsResponse(response: CursorModelsResponse | null): response ); } +/** + * Bumped when a cached catalog can no longer be trusted. v1 carried synthesized + * `[fast=…]` wire ids that Cursor rejects at `session/new`; `listCursorModels` + * serves the on-disk cache without re-probing, so stale entries must not be read. + */ +const SHARED_CACHE_VERSION = 2; + +type SharedCacheEnvelope = { + version: number; + response: CursorModelsResponse; +}; + /** Cross-process catalog for New Session while an ACP lock blocks `agent --list-models`. */ export function readSharedCursorModelsCache(): CursorModelsResponse | null { const path = getSharedCachePath(); @@ -26,8 +38,12 @@ export function readSharedCursorModelsCache(): CursorModelsResponse | null { } try { - const parsed = JSON.parse(readFileSync(path, 'utf8')) as CursorModelsResponse; - return isUsableModelsResponse(parsed) ? parsed : null; + const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial | null; + if (!parsed || parsed.version !== SHARED_CACHE_VERSION) { + return null; + } + const response = parsed.response ?? null; + return isUsableModelsResponse(response) ? response : null; } catch { return null; } @@ -41,7 +57,8 @@ export function writeSharedCursorModelsCache(response: CursorModelsResponse): vo const path = getSharedCachePath(); try { mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(response), 'utf8'); + const envelope: SharedCacheEnvelope = { version: SHARED_CACHE_VERSION, response }; + writeFileSync(path, JSON.stringify(envelope), 'utf8'); } catch { // Best effort — in-process cache still works in the session child. } diff --git a/e2e/composer-copy.spec.ts b/e2e/composer-copy.spec.ts index d6f353c0..a4e8a586 100644 --- a/e2e/composer-copy.spec.ts +++ b/e2e/composer-copy.spec.ts @@ -16,11 +16,11 @@ test.describe('composer Ctrl+A + Ctrl+C copy', () => { }) test('Ctrl+A outside the composer selects the message thread and Ctrl+C copies it', async ({ page }) => { - await page.keyboard.press('Control+a') + await page.keyboard.press('ControlOrMeta+a') const selected = await page.evaluate(() => window.getSelection()?.toString() ?? '') expect(selected).toContain('The quick brown fox jumps over the lazy dog') expect(selected).toContain('my earlier user message') - await page.keyboard.press('Control+c') + await page.keyboard.press('ControlOrMeta+c') const copied = await page.evaluate(() => navigator.clipboard.readText()) expect(copied).toBe(selected) }) @@ -29,8 +29,8 @@ test.describe('composer Ctrl+A + Ctrl+C copy', () => { const editor = page.locator('[data-testid="rich-composer-input"]') await editor.click() await page.keyboard.type('my draft message') - await page.keyboard.press('Control+a') - await page.keyboard.press('Control+c') + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('ControlOrMeta+c') const copied = await page.evaluate(() => navigator.clipboard.readText()) expect(copied).toBe('my draft message') }) @@ -44,8 +44,8 @@ test.describe('composer Ctrl+A + Ctrl+C copy', () => { }) const textarea = page.locator('#plain-textarea') await textarea.click() - await page.keyboard.press('Control+a') - await page.keyboard.press('Control+c') + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('ControlOrMeta+c') const copied = await page.evaluate(() => navigator.clipboard.readText()) expect(copied).toBe('textarea draft') }) @@ -53,10 +53,10 @@ test.describe('composer Ctrl+A + Ctrl+C copy', () => { test('mouse-drag selection still copies via plain Ctrl+C', async ({ page }) => { const msg = page.locator('[data-testid="assistant-message-2"]') await msg.dragTo(page.locator('[data-testid="user-message-1"]'), { - sourcePosition: { x: 5, y: 2 }, + sourcePosition: { x: 0, y: 2 }, targetPosition: { x: 300, y: 2 }, }) - await page.keyboard.press('Control+c') + await page.keyboard.press('ControlOrMeta+c') const copied = await page.evaluate(() => navigator.clipboard.readText()) expect(copied).toContain('Another assistant message') expect(copied).toContain('my earlier user message') diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 4a871fa5..d45fd7e4 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -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 diff --git a/shared/src/cursorCliSku.test.ts b/shared/src/cursorCliSku.test.ts index 36316337..96351839 100644 --- a/shared/src/cursorCliSku.test.ts +++ b/shared/src/cursorCliSku.test.ts @@ -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); diff --git a/shared/src/cursorCliSku.ts b/shared/src/cursorCliSku.ts index 0414d33f..57479ff7 100644 --- a/shared/src/cursorCliSku.ts +++ b/shared/src/cursorCliSku.ts @@ -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 { return params; } +/** + * Parameter hints carried by a CLI `agent --list-models` sku + * (effort/reasoning/thinking/fast suffixes). + */ +export function parseCursorSkuParamHints(slug: string): Record { + return inferSkuParamHints(slug); +} + function inferSkuParamHints(slug: string): Record { const lower = slug.toLowerCase(); const hints: Record = {}; diff --git a/web/README.md b/web/README.md index 4b65b18c..ff30e448 100644 --- a/web/README.md +++ b/web/README.md @@ -65,6 +65,7 @@ See `src/router.tsx` for route definitions. - Message thread with infinite scroll. - Composer for sending messages. - Permission mode and model selection for supported agents. + Cursor Auto uses CLI Auto for new/resumed sessions configured with Auto. When ACP does not advertise a literal Auto option, the session picker warns that switching back from a concrete model requires a restart; HAPI does not automatically restart an active session. - Session abort and handoff controls. - Codex **Continue planning** hides the current proposal's action menu locally and focuses the composer without sending a message or replacing its draft. diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 03d8f4f7..78ddb242 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1645,10 +1645,9 @@ export function HappyComposer(props: { } if (modelOptions.length === 0) return undefined const rawKey = selectedModelBase !== undefined ? selectedModelBase : model - // `null` (default selection) and the `auto`/`default` wire values all - // mean "let the agent pick" — normalize them onto the `value: null` - // option so the localized option label is always found. - const normalizedKey = !rawKey || rawKey === 'auto' || rawKey === 'default' ? null : rawKey + const normalizedKey = agentFlavor === 'cursor' + ? (!rawKey || rawKey === 'auto' || rawKey === 'default' || rawKey === 'default[]' ? 'auto' : rawKey) + : (!rawKey || rawKey === 'auto' || rawKey === 'default' ? null : rawKey) const option = modelOptions.find((candidate) => candidate.value === normalizedKey) return option?.label ?? rawKey ?? undefined }, [isNarrowViewport, onModelChange, agentFlavor, selectedPiModel, model, modelOptions, selectedModelBase]) diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index c0b0eea1..e4cb4410 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -137,7 +137,7 @@ describe('getModelOptionsForFlavor', () => { it('returns only default/current for cursor before models are discovered (no claude fallback)', () => { const options = getModelOptionsForFlavor('cursor', 'composer-2.5') expect(options).toEqual([ - { value: null, label: 'Auto' }, + { value: 'auto', label: 'Auto' }, { value: 'composer-2.5', label: 'composer-2.5' } ]) }) @@ -156,12 +156,12 @@ describe('getModelOptionsForFlavor', () => { it('does not inject raw wire id when dual picker base is already listed', () => { const wire = 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]' const options = getModelOptionsForFlavor('cursor', wire, [ - { value: null, label: 'Auto' }, + { value: 'auto', label: 'Auto' }, { value: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, { value: 'composer-2.5', label: 'Composer 2.5' }, ]) expect(options).toEqual([ - { value: null, label: 'Auto' }, + { value: 'auto', label: 'Auto' }, { value: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, { value: 'composer-2.5', label: 'Composer 2.5' }, ]) @@ -170,11 +170,11 @@ describe('getModelOptionsForFlavor', () => { it('injects unknown wire id only when catalog lacks base and wire', () => { const wire = 'claude-opus-4-9[effort=high,fast=false]' const options = getModelOptionsForFlavor('cursor', wire, [ - { value: null, label: 'Auto' }, + { value: 'auto', label: 'Auto' }, { value: 'composer-2.5', label: 'Composer 2.5' }, ]) expect(options).toEqual([ - { value: null, label: 'Auto' }, + { value: 'auto', label: 'Auto' }, { value: wire, label: wire }, { value: 'composer-2.5', label: 'Composer 2.5' }, ]) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index d68e5a63..f40e42ef 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -40,7 +40,7 @@ function withCurrentModelOption( } const nextOptions = [...options] - const autoIndex = nextOptions.findIndex((option) => option.value === null) + const autoIndex = nextOptions.findIndex((option) => option.value === null || option.value === 'auto') nextOptions.splice(autoIndex >= 0 ? autoIndex + 1 : 0, 0, { value: normalizedCurrentModel, label: resolveLabel?.(normalizedCurrentModel) ?? normalizedCurrentModel @@ -144,7 +144,7 @@ export function getModelOptionsForFlavor( return [] } if (flavor === 'cursor') { - return withCurrentModelOption([{ value: null, label: CURSOR_AUTO_MODEL_LABEL }], currentModel) + return withCurrentModelOption([{ value: 'auto', label: CURSOR_AUTO_MODEL_LABEL }], currentModel) } // Kimi has no predefined model list — show just the auto/default option. if (flavor === 'kimi') { diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 934f6bbd..0a9dbeec 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1534,7 +1534,9 @@ export function NewSession(props: { ? (opencodeSelectedModel ?? undefined) : agent === 'agy' ? (agySelectedModel ?? undefined) - : (model !== 'auto' ? model : undefined) + : agent === 'cursor' + ? (model === 'auto' || !model ? 'auto' : model) + : (model !== 'auto' ? model : undefined) const resolvedEffort = (agent === 'claude' || agent === 'grok' || agent === 'pi') && effort !== 'auto' ? effort : undefined diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index e59d3673..14f9ffae 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1092,7 +1092,8 @@ function SessionChatInner(props: SessionChatProps) { machineModels: machineCursorModelsState.availableModels, cliModelSkus: sessionCliModelSkus, sessionModel: props.session.model, - sessionCurrentModelId: cursorModelsState.currentModelId + sessionCurrentModelId: cursorModelsState.currentModelId, + autoRestartLabel: t('session.modelChange.cursorAutoRestart') }) }, [ agentFlavor, @@ -1100,7 +1101,8 @@ function SessionChatInner(props: SessionChatProps) { cursorModelsState.currentModelId, machineCursorModelsState.availableModels, sessionCliModelSkus, - props.session.model + props.session.model, + t ]) const agyModelsState = useAgyModels({ api: props.api, diff --git a/web/src/lib/cursorCliSkuCatalog.test.ts b/web/src/lib/cursorCliSkuCatalog.test.ts index 6aae4bb7..09c1d608 100644 --- a/web/src/lib/cursorCliSkuCatalog.test.ts +++ b/web/src/lib/cursorCliSkuCatalog.test.ts @@ -36,4 +36,26 @@ describe('CLI sku variant catalog', () => { expect(variantIds).toContain('gpt-5.5-low') expect(variantIds.length).toBeGreaterThan(2) }) + + it('groups legacy cursor-prefixed skus under their ACP base (#1818)', () => { + const bareWires = [ + { modelId: 'grok-4.6', name: 'Cursor Grok 4.6' }, + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + ] + const catalog = appendCliSkusToCatalog(buildCursorModelCatalog(bareWires), [ + { modelId: 'cursor-grok-4.6-high', name: 'Cursor Grok 4.6' }, + { modelId: 'cursor-grok-4.6-high-fast', name: 'Cursor Grok 4.6 Fast' }, + { modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' }, + ]) + + const grokVariants = catalog.variantsByBase.get('grok-4.6') ?? [] + expect(grokVariants.map((row) => row.wireId)).toContain('cursor-grok-4.6-high') + expect(grokVariants.map((row) => row.wireId)).toContain('cursor-grok-4.6-high-fast') + expect(catalog.wireToBase.get('cursor-grok-4.6-high')).toBe('grok-4.6') + // The unrelated prefix group must not absorb other bases' skus. + expect(catalog.variantsByBase.get('composer-2.5')?.map((row) => row.wireId)).toEqual([ + 'composer-2.5', + 'composer-2.5-fast' + ]) + }) }) diff --git a/web/src/lib/cursorModelOptions.test.ts b/web/src/lib/cursorModelOptions.test.ts index ea6b654d..b264a0a8 100644 --- a/web/src/lib/cursorModelOptions.test.ts +++ b/web/src/lib/cursorModelOptions.test.ts @@ -74,7 +74,7 @@ describe('buildCursorModelCatalog', () => { { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' }, ]) expect(catalog.baseOptions.map((o) => o.value)).toEqual([ - null, + 'auto', 'composer-2.5', 'composer-2.5-fast', ]) diff --git a/web/src/lib/cursorModelOptions.ts b/web/src/lib/cursorModelOptions.ts index 6bf17986..f720b379 100644 --- a/web/src/lib/cursorModelOptions.ts +++ b/web/src/lib/cursorModelOptions.ts @@ -1,8 +1,11 @@ import { + CURSOR_AUTO_MODEL_ID, cursorCliSkuBaseId, + cursorModelBaseMatches, findBestCliSkuForAcpWire, isCursorAcpCatalogModelId, - isCursorAcpWireModelId as isSharedCursorAcpWireModelId + isCursorAcpWireModelId as isSharedCursorAcpWireModelId, + isCursorAutoModelId } from '@hapi/protocol' import type { CursorModelSummary } from '@/types/api' @@ -46,14 +49,25 @@ export function cursorModelDedupeKey(modelId: string): string { return cursorModelBaseId(modelId) } -function isDefaultCursorModelId(modelId: string): boolean { - const normalized = modelId.trim().toLowerCase() - return normalized === 'auto' || normalized === 'default' || normalized === 'default[]' +/** Existing catalog base for a CLI sku base, tolerating the legacy `cursor-` prefix. */ +function findCatalogBaseKey( + catalog: CursorModelCatalog, + skuBaseId: string +): string | null { + if (catalog.variantsByBase.has(skuBaseId)) { + return skuBaseId + } + for (const baseKey of catalog.variantsByBase.keys()) { + if (cursorModelBaseMatches(skuBaseId, baseKey)) { + return baseKey + } + } + return null } function normalizeCurrentModel(model?: string | null): string | null { const trimmed = model?.trim() - if (!trimmed || isDefaultCursorModelId(trimmed)) { + if (!trimmed || isCursorAutoModelId(trimmed)) { return null } return trimmed @@ -199,18 +213,18 @@ export function buildCursorModelCatalog( availableModels: readonly CursorModelSummary[], options?: { currentModel?: string | null - /** New-session spawn uses `auto`; active session uses `null` for default. */ + /** Auto row is always CLI `auto`; kept for caller compatibility. */ defaultValue?: null | 'auto' } ): CursorModelCatalog { - const defaultValue = options?.defaultValue === 'auto' ? 'auto' : null + const defaultValue = CURSOR_AUTO_MODEL_ID const variantsByBase = new Map() const wireToBase = new Map() const baseLabels = new Map() const addWire = (rawModelId: string): void => { const modelId = rawModelId.trim() - if (!modelId || isDefaultCursorModelId(modelId)) { + if (!modelId || isCursorAutoModelId(modelId)) { return } @@ -262,13 +276,16 @@ export function appendCliSkusToCatalog( for (const sku of cliSkus) { const modelId = sku.modelId.trim() - if (!modelId || isDefaultCursorModelId(modelId) || isCursorAcpWireModelId(modelId)) { + if (!modelId || isCursorAutoModelId(modelId) || isCursorAcpWireModelId(modelId)) { continue } const baseId = cursorCliSkuBaseId(modelId) - const existing = catalog.variantsByBase.get(baseId) - if (!existing || existing.length === 0) { + // CLI sku bases may carry the legacy `cursor-` family prefix + // (`cursor-grok-4.6-high` → ACP base `grok-4.6`). + const catalogBaseId = findCatalogBaseKey(catalog, baseId) + const existing = catalogBaseId ? catalog.variantsByBase.get(catalogBaseId) : undefined + if (!catalogBaseId || !existing || existing.length === 0) { continue } @@ -281,7 +298,7 @@ export function appendCliSkusToCatalog( label: sku.name?.trim() && sku.name !== modelId ? sku.name.trim() : modelId, sortKey: modelId }) - catalog.wireToBase.set(modelId, baseId) + catalog.wireToBase.set(modelId, catalogBaseId) } return catalog @@ -354,7 +371,7 @@ export function buildFlatCursorModelPickerOptions( catalog: CursorModelCatalog, options?: { defaultValue?: null | 'auto' } ): Array<{ value: string; label: string }> { - const defaultValue = options?.defaultValue === 'auto' ? 'auto' : null + const defaultValue = CURSOR_AUTO_MODEL_ID const rows: Array<{ value: string; label: string }> = [] for (const [baseId, variants] of catalog.variantsByBase) { diff --git a/web/src/lib/cursorPickerState.ts b/web/src/lib/cursorPickerState.ts index 31ec6ed2..f5a3a025 100644 --- a/web/src/lib/cursorPickerState.ts +++ b/web/src/lib/cursorPickerState.ts @@ -1,4 +1,4 @@ -import { isCursorAcpCatalogModelId } from '@hapi/protocol' +import { CURSOR_AUTO_MODEL_ID, isCursorAcpCatalogModelId, isCursorAutoModelId } from '@hapi/protocol' import type { CursorModelSummary } from '@/types/api' import { appendCliSkusToCatalog, @@ -118,11 +118,11 @@ export function buildCursorCatalogFromSources(args: { export function normalizeCursorPickerWireId( wireId: string | null | undefined, - defaultToken: 'auto' | null = null + defaultToken: 'auto' | null = CURSOR_AUTO_MODEL_ID ): string | null { const trimmed = wireId?.trim() - if (!trimmed || trimmed === 'auto' || trimmed === 'default' || trimmed === 'default[]') { - return defaultToken + if (!trimmed || isCursorAutoModelId(trimmed)) { + return defaultToken ?? CURSOR_AUTO_MODEL_ID } return trimmed } @@ -132,7 +132,7 @@ export function buildCursorPickerState(args: { currentWireId?: string | null defaultValue?: null | 'auto' }): CursorPickerState { - const defaultToken = args.defaultValue === 'auto' ? 'auto' : null + const defaultToken = CURSOR_AUTO_MODEL_ID const wireId = normalizeCursorPickerWireId(args.currentWireId, defaultToken) const baseKey = wireId && wireId !== 'auto' ? resolveCursorBaseKey(wireId, args.catalog) @@ -149,7 +149,7 @@ export function buildCursorPickerState(args: { label: option.label })) : buildFlatCursorModelPickerOptions(args.catalog, { - defaultValue: args.defaultValue === 'auto' ? 'auto' : undefined + defaultValue: CURSOR_AUTO_MODEL_ID }).map((option) => ({ value: option.value ?? 'auto', label: option.label diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 6fc57569..2d5e8215 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -181,6 +181,7 @@ export default { 'session.item.model': 'model', 'session.item.machine': 'machine', 'session.modelChange.pending': 'Changing…', + 'session.modelChange.cursorAutoRestart': 'Auto (switching back requires restart)', 'session.modelChange.pendingTooltip': 'The model will change when the current task finishes.', 'session.item.worktree': 'worktree', 'session.item.pending': 'pending', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 25378bb8..6dca4945 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -181,6 +181,7 @@ export default { 'session.item.model': '模型', 'session.item.machine': '机器', 'session.modelChange.pending': '更改中…', + 'session.modelChange.cursorAutoRestart': 'Auto(切回需要重启)', 'session.modelChange.pendingTooltip': '当前任务完成后将更改模型。', 'session.item.worktree': '工作树', 'session.item.pending': '待处理', diff --git a/web/src/lib/sessionChatCursorModel.test.ts b/web/src/lib/sessionChatCursorModel.test.ts index 66763809..7bf1992e 100644 --- a/web/src/lib/sessionChatCursorModel.test.ts +++ b/web/src/lib/sessionChatCursorModel.test.ts @@ -16,6 +16,18 @@ const sessionModels = [ ] as const describe('resolveSessionCursorModelChange', () => { + it.each([false, true])('labels the live Auto limitation only without native Auto (native=%s)', (nativeAuto) => { + const picker = buildSessionCursorPickerState({ + sessionModels: [{ modelId: nativeAuto ? 'auto' : 'default' }, ...sessionModels], + machineModels: [{ modelId: 'auto' }], + sessionModel: 'composer-2.5[fast=true]', + sessionCurrentModelId: 'composer-2.5[fast=true]', + autoRestartLabel: 'Auto (switching back requires restart)' + }) + expect(picker.modelOptions.find(option => option.value === 'auto')?.label) + .toBe(nativeAuto ? 'Auto' : 'Auto (switching back requires restart)') + }) + const picker = buildSessionCursorPickerState({ sessionModels, machineModels: [], @@ -39,6 +51,22 @@ describe('resolveSessionCursorModelChange', () => { }) }) + it('selecting Auto applies the CLI auto model id', () => { + const plan = resolveSessionCursorModelChange({ + picker, + sessionModel: 'composer-2.5[fast=true]', + cursorSelectedBase: 'composer-2.5', + kind: 'base', + value: 'auto' + }) + expect(plan).toEqual({ + ok: true, + wireId: 'auto', + nextSelectedBase: 'auto', + shouldApply: true + }) + }) + it('applies a base change when the base has exactly one wire variant', () => { const singlePicker = buildSessionCursorPickerState({ sessionModels: [{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]' }], diff --git a/web/src/lib/sessionChatCursorModel.ts b/web/src/lib/sessionChatCursorModel.ts index e9075542..6dc015b1 100644 --- a/web/src/lib/sessionChatCursorModel.ts +++ b/web/src/lib/sessionChatCursorModel.ts @@ -45,7 +45,7 @@ export function resolveSessionCursorModelChange(args: { if (kind === 'base') { if (!value || value === 'auto') { - return { ok: true, wireId: null, nextSelectedBase: 'auto', shouldApply: true } + return { ok: true, wireId: 'auto', nextSelectedBase: 'auto', shouldApply: true } } if (value.includes('[')) { const base = resolveCursorBaseFromWire(value, picker.catalog) @@ -64,7 +64,7 @@ export function resolveSessionCursorModelChange(args: { const wireId = resolveWireIdForBaseChange(value, picker.catalog, sessionModel) return { ok: true, - wireId: wireId === 'auto' ? null : wireId, + wireId: wireId === 'auto' ? 'auto' : wireId, nextSelectedBase: value, shouldApply: wireId !== null } @@ -83,7 +83,7 @@ export function resolveSessionCursorModelChange(args: { // flat picker: value is wire id or auto if (!value || value === 'auto') { - return { ok: true, wireId: null, nextSelectedBase: 'auto', shouldApply: true } + return { ok: true, wireId: 'auto', nextSelectedBase: 'auto', shouldApply: true } } if (!picker.catalog.wireToBase.has(value)) { return { ok: false, reason: 'model wire id not in catalog' } @@ -208,6 +208,7 @@ export function buildSessionCursorPickerState(args: { cliModelSkus?: readonly CursorModelSummary[] sessionModel: string | null | undefined sessionCurrentModelId: string | null + autoRestartLabel?: string }): CursorPickerState { const catalog = buildCursorCatalogFromSources({ sessionModels: args.sessionModels, @@ -215,11 +216,18 @@ export function buildSessionCursorPickerState(args: { cliModelSkus: args.cliModelSkus, currentWireId: args.sessionCurrentModelId ?? args.sessionModel, sessionModelFromHub: args.sessionModel, - defaultValue: null + defaultValue: 'auto' }) - return buildCursorPickerState({ + const picker = buildCursorPickerState({ catalog, currentWireId: args.sessionModel ?? args.sessionCurrentModelId, - defaultValue: null + defaultValue: 'auto' }) + const autoRestartLabel = args.autoRestartLabel + if (autoRestartLabel && !args.sessionModels.some(model => model.modelId.trim() === 'auto')) { + picker.modelOptions = picker.modelOptions.map(option => option.value === 'auto' + ? { ...option, label: autoRestartLabel } + : option) + } + return picker }