diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts new file mode 100644 index 00000000..6dc851b7 --- /dev/null +++ b/cli/src/agent/backends/acp/AcpSdkBackend.initialize.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +const transportState = vi.hoisted(() => ({ + calls: [] as Array<{ method: string; params?: unknown }> +})); + +vi.mock('./AcpStdioTransport', () => ({ + AcpStdioTransport: class { + constructor(_options: unknown) {} + onNotification = vi.fn(); + onStderrError = vi.fn(); + registerRequestHandler = vi.fn(); + sendRequest = vi.fn(async (method: string, params?: unknown) => { + transportState.calls.push({ method, params }); + if (method === 'initialize') { + return { protocolVersion: 1, authMethods: [] }; + } + return null; + }); + close = vi.fn(async () => {}); + } +})); + +import { AcpSdkBackend } from './AcpSdkBackend'; + +describe('AcpSdkBackend.initialize', () => { + it('advertises Cursor-compatible parameterized model picker support', async () => { + const backend = new AcpSdkBackend({ command: 'agent', args: ['acp'] }); + + await backend.initialize(); + + expect(transportState.calls).toContainEqual({ + method: 'initialize', + params: expect.objectContaining({ + clientCapabilities: expect.objectContaining({ + _meta: expect.objectContaining({ + parameterizedModelPicker: true + }) + }) + }) + }); + }); +}); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 13191a5a..b82421a9 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -131,7 +131,13 @@ export class AcpSdkBackend implements AgentBackend { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, - terminal: false + terminal: false, + _meta: { + // Cursor ACP exposes Composer's non-fast/fast choice as separate + // `model` + `fast` config options only when the client advertises + // this capability. Agents that do not know this metadata ignore it. + parameterizedModelPicker: true + } }, clientInfo: { name: 'hapi', @@ -839,7 +845,7 @@ export class AcpSdkBackend implements AgentBackend { for (const entry of response.configOptions) { if (!isObject(entry)) continue; - if (asString(entry.category) !== 'model') continue; + if (asString(entry.category) !== 'model' && asString(entry.id) !== 'model') continue; return { currentValue: asString(entry.currentValue), options: Array.isArray(entry.options) ? entry.options : [] diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts index 2aaa652d..8b4903c3 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.test.ts @@ -47,4 +47,50 @@ describe('buildCursorModelsSnapshotFromAcp', () => { expect(snapshot?.availableModels).toHaveLength(2); }); + + it('synthesizes Composer fast variants from parameterized model + fast config options', () => { + const backend = { + getSessionModelsMetadata: () => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + }), + getConfigOptionByCategory: (_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + currentValue: 'false', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }, + getSessionConfigOptions: () => [ + { + id: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }, + { + id: 'fast', + currentValue: 'false', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + } + ] + }; + + 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]'); + }); }); diff --git a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts index 3d836da1..5c3b27e2 100644 --- a/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts +++ b/cli/src/cursor/utils/cursorAcpModelsSnapshot.ts @@ -6,6 +6,18 @@ export type CursorModelsSnapshot = { currentModelId: string | null; }; +type CursorAcpModelSnapshotBackend = Pick + & Partial>; + +function findConfigOption( + backend: CursorAcpModelSnapshotBackend, + sessionId: string, + key: string +) { + return backend.getConfigOptionByCategory?.(sessionId, key) + ?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key); +} + function mergeModelEntries( target: Map, entries: Iterable<{ modelId: string; name?: string | null }> @@ -31,19 +43,43 @@ function mergeModelEntries( * `availableModels` alone is often one variant per base family. */ export function buildCursorModelsSnapshotFromAcp( - backend: Pick, + backend: CursorAcpModelSnapshotBackend, sessionId: string ): CursorModelsSnapshot | null { const metadata = backend.getSessionModelsMetadata(sessionId); - const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + 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) { + 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) { mergeModelEntries(merged, modelOption.options.map((option) => ({ modelId: option.value, name: option.name @@ -51,16 +87,23 @@ export function buildCursorModelsSnapshotFromAcp( } if (metadata?.availableModels?.length) { - mergeModelEntries(merged, metadata.availableModels); + mergeModelEntries( + merged, + parameterizedFastModels.length > 0 + ? metadata.availableModels.filter((entry) => entry.modelId.includes('[')) + : metadata.availableModels + ); } if (merged.size === 0) { return null; } - const currentModelId = metadata?.currentModelId - ?? modelOption?.currentValue - ?? null; + const currentModelId = parameterizedFastModels.length > 0 && modelOption?.currentValue && fastOption?.currentValue + ? `${modelOption.currentValue}[fast=${fastOption.currentValue}]` + : metadata?.currentModelId + ?? modelOption?.currentValue + ?? null; return { availableModels: [...merged.values()], diff --git a/cli/src/cursor/utils/cursorModeConfig.test.ts b/cli/src/cursor/utils/cursorModeConfig.test.ts index ea4c39db..63a0cde3 100644 --- a/cli/src/cursor/utils/cursorModeConfig.test.ts +++ b/cli/src/cursor/utils/cursorModeConfig.test.ts @@ -194,6 +194,109 @@ describe('applyCursorAcpModel', () => { expect(setConfigOption).toHaveBeenCalledWith('s1', 'model-opt', 'composer-2.5[fast=false]'); }); + it('applies parameterized Cursor Composer fast=false for base CLI sku requests', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + category: 'model', + currentValue: 'composer-2.5', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + category: 'fast', + currentValue: 'true', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({ + applied: true, + resolvedWireId: 'composer-2.5[fast=false]', + requestedWireId: 'composer-2.5' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'false'); + expect(backend.pinSessionModelWireId).toHaveBeenCalledWith('s1', 'composer-2.5[fast=false]'); + }); + + it('applies parameterized Cursor Composer fast=true for -fast CLI sku requests', async () => { + const setConfigOption = vi.fn(async () => {}); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { id: 'model', category: 'model', options: [{ value: 'composer-2.5' }] }; + } + if (category === 'fast') { + return { id: 'fast', category: 'fast', options: [{ value: 'false' }, { value: 'true' }] }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5-fast')).resolves.toMatchObject({ + applied: true, + resolvedWireId: 'composer-2.5[fast=true]', + requestedWireId: 'composer-2.5-fast' + }); + expect(setConfigOption).toHaveBeenNthCalledWith(1, 's1', 'model', 'composer-2.5'); + expect(setConfigOption).toHaveBeenNthCalledWith(2, 's1', 'fast', 'true'); + }); + + it('does not fall back to base-only model apply when parameterized fast update fails', async () => { + const setConfigOption = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('fast update failed')); + const backend = mockModelBackend({ + setConfigOption, + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'composer-2.5', name: 'Composer 2.5' }], + currentModelId: 'composer-2.5' + })), + getConfigOptionByCategory: vi.fn((_sessionId: string, category: string) => { + if (category === 'model') { + return { + id: 'model', + category: 'model', + options: [{ value: 'composer-2.5', name: 'Composer 2.5' }] + }; + } + if (category === 'fast') { + return { + id: 'fast', + category: 'fast', + options: [{ value: 'false', name: 'Off' }, { value: 'true', name: 'Fast' }] + }; + } + return undefined; + }) + }); + + await expect(applyCursorAcpModel(backend, 's1', 'composer-2.5')).resolves.toEqual({ + applied: false + }); + expect(setConfigOption).toHaveBeenCalledTimes(2); + expect(backend.pinSessionModelWireId).not.toHaveBeenCalled(); + }); + it('retries set_config_option once before failing apply', async () => { const setConfigOption = vi.fn() .mockRejectedValueOnce(new Error('transient')) diff --git a/cli/src/cursor/utils/cursorModeConfig.ts b/cli/src/cursor/utils/cursorModeConfig.ts index 2f21a3b3..ed7da60a 100644 --- a/cli/src/cursor/utils/cursorModeConfig.ts +++ b/cli/src/cursor/utils/cursorModeConfig.ts @@ -1,5 +1,5 @@ import type { CursorPermissionMode } from '@hapi/protocol/types'; -import { matchCliSkuToAcpWireId } from '@hapi/protocol'; +import { cursorCliSkuBaseId, cursorModelBaseId, matchCliSkuToAcpWireId } from '@hapi/protocol'; import type { AcpSdkBackend } from '@/agent/backends/acp'; import { logger } from '@/ui/logger'; @@ -71,6 +71,9 @@ export type ApplyCursorAcpModelResult = { requestedWireId?: string; }; +type ConfigOption = NonNullable>; +type ParameterizedCursorModelResult = ApplyCursorAcpModelResult | 'unsupported' | 'failed'; + /** Wire id stored on session + keepalive (preserve explicit variant picks). */ export function wireIdForCursorSessionState(requested: string, resolved: string): string { const trimmed = requested.trim(); @@ -101,6 +104,61 @@ export function resolveCursorAcpWireId( return matchCliSkuToAcpWireId(trimmed, available); } +function findConfigOption(backend: AcpSdkBackend, sessionId: string, key: string): ConfigOption | undefined { + return backend.getConfigOptionByCategory?.(sessionId, key) + ?? backend.getSessionConfigOptions?.(sessionId)?.find((option) => option.id === key || option.category === key); +} + +function optionHasValue(option: ConfigOption | undefined, value: string): boolean { + return Boolean(option?.options?.some((entry) => entry.value === value)); +} + +function fastHintForCursorSkuOrWire(modelId: string): 'false' | 'true' { + const lower = modelId.trim().toLowerCase(); + const fastMatch = lower.match(/[\[,](?:\s*)fast=(true|false)(?:\s*)[\],]/) + ?? lower.match(/\[\s*fast=(true|false)\s*\]/); + if (fastMatch?.[1] === 'true' || fastMatch?.[1] === 'false') { + return fastMatch[1]; + } + return lower.includes('-fast') ? 'true' : 'false'; +} + +function cursorRequestBaseId(modelId: string): string { + return modelId.includes('[') + ? cursorModelBaseId(modelId) + : cursorCliSkuBaseId(modelId); +} + +async function applyParameterizedCursorModel( + backend: AcpSdkBackend, + sessionId: string, + requested: string +): Promise { + const modelOption = findConfigOption(backend, sessionId, 'model'); + const fastOption = findConfigOption(backend, sessionId, 'fast'); + if (!modelOption || !fastOption || !backend.setConfigOption) { + return 'unsupported'; + } + + const baseModel = cursorRequestBaseId(requested); + const fast = fastHintForCursorSkuOrWire(requested); + if (!optionHasValue(modelOption, baseModel) || !optionHasValue(fastOption, fast)) { + return 'unsupported'; + } + + try { + await backend.setConfigOption(sessionId, modelOption.id, baseModel); + await backend.setConfigOption(sessionId, fastOption.id, fast); + } catch (error) { + logger.debug('[cursor-acp] parameterized model config failed', error); + return 'failed'; + } + + const resolved = `${baseModel}[fast=${fast}]`; + backend.pinSessionModelWireId(sessionId, resolved); + return { applied: true, resolvedWireId: resolved, requestedWireId: requested }; +} + /** * Apply a model from the live ACP configOptions list (Zed-style). * Only wire ids present in `availableModels` are accepted. @@ -118,6 +176,15 @@ export async function applyCursorAcpModel( const metadata = backend.getSessionModelsMetadata(sessionId); const available = metadata?.availableModels ?? []; const modelOption = backend.getConfigOptionByCategory?.(sessionId, 'model'); + + const parameterized = await applyParameterizedCursorModel(backend, sessionId, trimmed); + if (parameterized === 'failed') { + return { applied: false }; + } + if (parameterized !== 'unsupported') { + return parameterized; + } + const optionWireIds = modelOption?.options?.map((option) => ({ modelId: option.value })) ?? []; const catalog = [...available, ...optionWireIds]; const resolved = resolveCursorAcpWireId(trimmed, catalog); diff --git a/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx b/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx new file mode 100644 index 00000000..f9966d60 --- /dev/null +++ b/web/src/components/AssistantChat/HappyComposer.modelEffort.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ModelEffortSettingsSection } from './HappyComposer'; + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ + t: (key: string) => key === 'misc.variant' ? 'Variant' : key + }) +})); + +describe('ModelEffortSettingsSection', () => { + it('renders Cursor variant choices and marks the selected variant', () => { + render( + {}} + /> + ); + + expect(screen.getByText('Variant')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Composer 2.5$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Composer 2.5 Fast/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Composer 2.5$/ }).innerHTML).toContain('bg-[var(--app-link)]'); + }); +}); diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 10287ae6..e2639d2a 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -81,6 +81,57 @@ export type ComposerSendError = { const defaultSuggestionHandler = async (): Promise => [] +export function ModelEffortSettingsSection(props: { + agentFlavor?: string | null + options: Array<{ value: string; label: string }> + selectedValue: string | null | undefined + controlsDisabled: boolean + onChange: (value: string) => void +}) { + const { t } = useTranslation() + const { agentFlavor, options, selectedValue, controlsDisabled, onChange } = props + + return ( +
+
+ {agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')} +
+ {options.map((option) => { + const isSelected = selectedValue === option.value + return ( + + ) + })} +
+ ) +} + export function HappyComposer(props: { sessionId?: string disabled?: boolean @@ -984,6 +1035,24 @@ export function HappyComposer(props: { ) : null} + {showModelSettings && showModelEffortSettings ? ( +
+ ) : null} + + {showModelEffortSettings ? ( + + ) : null} + + {(showModelSettings || showModelEffortSettings) && showModelReasoningEffortSettings ? ( +
+ ) : null} + {(showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings) && showEffortSettings ? (
) : null}