diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index af4c8cf4e..77f690671 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -276,7 +276,12 @@ Two things the dev build does deliberately, both load-bearing: publish the legacy `model[effort]` list (Codex); effort is validated against the TARGET model and the ids so validated come back as `validatedConfigIds`, which `validateTurnConfigOptionValues(..., skipIds)` must skip (the probed model's list - would wrongly reject them). What cannot be checked offline is dispatched as + would wrongly reject them). When the cache carries `configOptionsByModel` (registry + Cursor), the semantic mapping, turn validation, and inherited-default filtering all + read the TARGET model's composed options through `resolveAcpConfigOptionsForModel`; + inherited create defaults are filtered against the MERGED target model, and an + explicit create `modelId` drops a parent's superseded `model` option so the frozen + Turn names one model. What cannot be checked offline is dispatched as requested. Runtime rejections remain in debug diagnostics; Codex/Claude mismatches for model, reasoning effort, Fast, or Plan are not promoted to visible `agent_warning` notices, while other rejected selections still are. Compatibility exception: Claude diff --git a/apps/cli/src/agent/AGENTS.md b/apps/cli/src/agent/AGENTS.md index bd930c053..81d96e825 100644 --- a/apps/cli/src/agent/AGENTS.md +++ b/apps/cli/src/agent/AGENTS.md @@ -81,6 +81,11 @@ arrive: context/message-flow.md "Upstream". answer before giving up on the upstream turn's response: the Codex adapter drains session notifications before refusing, so the turn's response routinely wins that race and would otherwise mask the refusal. + Registry Cursor opts into cursor-agent's clean model ids via + `clientCapabilities._meta.parameterizedModelPicker` at initialize; the gate is + registry identity (`cliType: 'registry'` and `agentType: 'cursor'`), never a + same-named custom or builtin config. Downstream capability consumers stay + provider-neutral. - `acp-runner.ts` — process spawn/restart around the client. Spawn + initialize + `newSession`/`loadSession` share `acp-session-start-gate.ts` (default 2, `LODY_MAX_CONCURRENT_ACP_SESSION_STARTS`). Unbounded concurrent Codex starts @@ -235,6 +240,17 @@ arrive: context/message-flow.md "Upstream". non-blocking cache update before the first prompt. Machine Flock writes ignore `fetchedAt` when comparing entries, so unchanged runtime capabilities do not commit or sync. + Registry Cursor's per-model option catalog (`AcpCapabilityCacheEntry.configOptionsByModel`) + comes only from an explicit `machine/acp-capabilities-refresh` probe calling + `cursor/list_available_models` once after `session/new`; real sessions never fetch it. + JSON-RPC `-32601` means no catalog; any other failure fails the probe with + `[ACP_CAPABILITIES_INCOMPLETE]` so the settings test button can retry. Omitting + `configOptionsByModel` on a Machine Flock write preserves the stored catalog for the + same `sourceVersion`, and the unchanged-entry comparison includes it. Never enumerate + models through `session/set_config_option`: it rewrites the user's global Cursor config. + `resolveAcpConfigOptionsForModel` in `@lody/shared` is the one composition rule: an + option owned by any model's catalog entry is per-model, and `model`/`mode` options + always come from the snapshot. - `login-shell-env.ts` — login-shell env capture for spawned agents. - Builtin Claude owns session title generation through ACP `session_info_update`; `AgentClient` forwards those titles and `MessageHandler` diff --git a/apps/cli/src/agent/acp-capabilities.test.ts b/apps/cli/src/agent/acp-capabilities.test.ts index a9fa90408..69f8644b1 100644 --- a/apps/cli/src/agent/acp-capabilities.test.ts +++ b/apps/cli/src/agent/acp-capabilities.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ startLocalAcpAgent: vi.fn(), shutdownLocalAcpAgent: vi.fn(async () => {}), probeBuiltinAuthentication: vi.fn(), + fetchCursorModelCatalog: vi.fn(), })); vi.mock('./acp-runner', () => ({ @@ -17,6 +18,14 @@ vi.mock('./acp-authentication', () => ({ probeBuiltinAuthentication: mocks.probeBuiltinAuthentication, })); +vi.mock('./cursor-acp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchCursorModelCatalog: mocks.fetchCursorModelCatalog, + }; +}); + import { fetchAcpCapabilities } from './acp-capabilities'; import { AcpAuthenticationRequiredError } from './agent-client'; @@ -72,6 +81,7 @@ describe('fetchAcpCapabilities', () => { vi.clearAllMocks(); mocks.probeBuiltinAuthentication.mockResolvedValue({ status: 'unknown' }); mocks.startLocalAcpAgent.mockImplementation(async () => createSuccessfulStartupResult()); + mocks.fetchCursorModelCatalog.mockResolvedValue(undefined); }); it('defers builtin Codex authentication to ACP session creation', async () => { @@ -360,4 +370,45 @@ describe('fetchAcpCapabilities', () => { expect(result.configOptions).toBeUndefined(); }); + + it('attaches the Cursor model catalog for a registry Cursor probe', async () => { + const configOptionsByModel = { + 'model-full': [ + { + id: 'thinking', + name: 'Thinking', + type: 'select' as const, + currentValue: 'true', + options: [], + }, + ], + }; + mocks.fetchCursorModelCatalog.mockResolvedValue(configOptionsByModel); + + const result = await fetchAcpCapabilities('registry', 'cursor', createSilentLogger()); + + expect(result.configOptionsByModel).toEqual(configOptionsByModel); + expect(mocks.fetchCursorModelCatalog).toHaveBeenCalledTimes(1); + }); + + it('does not fetch a model catalog for custom or builtin probes', async () => { + const customResult = await fetchAcpCapabilities('custom', 'cursor', createSilentLogger()); + const builtinResult = await fetchAcpCapabilities('builtin', 'claude', createSilentLogger()); + + expect(customResult.configOptionsByModel).toBeUndefined(); + expect(builtinResult.configOptionsByModel).toBeUndefined(); + expect(mocks.fetchCursorModelCatalog).not.toHaveBeenCalled(); + }); + + it('shuts down the temp agent when the Cursor catalog fetch is incomplete', async () => { + const incomplete = new Error( + '[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models failed: boom' + ); + mocks.fetchCursorModelCatalog.mockRejectedValue(incomplete); + + await expect(fetchAcpCapabilities('registry', 'cursor', createSilentLogger())).rejects.toBe( + incomplete + ); + expect(mocks.shutdownLocalAcpAgent).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/cli/src/agent/acp-capabilities.ts b/apps/cli/src/agent/acp-capabilities.ts index 282e4aadb..a8c41ce98 100644 --- a/apps/cli/src/agent/acp-capabilities.ts +++ b/apps/cli/src/agent/acp-capabilities.ts @@ -1,4 +1,5 @@ import { + type AcpConfigOptionSummary, type AgentConfigCliType, type BuiltinRuntimeOverrides, type CustomAcpLaunchSpec, @@ -13,6 +14,7 @@ import { normalizeAcpSessionCapabilities, type AcpCapabilitiesResult, } from '@/agent/acp-capability-normalization'; +import { fetchCursorModelCatalog, isRegistryCursorAgent } from '@/agent/cursor-acp'; export { normalizeConfigOptions } from '@/agent/acp-capability-normalization'; export type { AcpCapabilitiesResult } from '@/agent/acp-capability-normalization'; @@ -24,6 +26,7 @@ export type FetchAcpCapabilitiesOptions = { export type FetchedAcpCapabilities = AcpCapabilitiesResult & { capabilitySourceVersion?: string; + configOptionsByModel?: Record; }; /** @@ -88,12 +91,17 @@ export async function fetchAcpCapabilities( }); try { + const normalized = normalizeAcpSessionCapabilities(sessionResponse, { + sessionFork: client.supportsSessionFork?.() === true, + acknowledgedSteer: client.supportsAcknowledgedSteer(), + }); + const configOptionsByModel = isRegistryCursorAgent({ cliType, agentType }) + ? await fetchCursorModelCatalog({ client, signal: options.signal, logger }) + : undefined; return { - ...normalizeAcpSessionCapabilities(sessionResponse, { - sessionFork: client.supportsSessionFork?.() === true, - acknowledgedSteer: client.supportsAcknowledgedSteer(), - }), + ...normalized, capabilitySourceVersion, + ...(configOptionsByModel !== undefined ? { configOptionsByModel } : {}), }; } finally { await shutdownLocalAcpAgent({ diff --git a/apps/cli/src/agent/agent-client-initialize.test.ts b/apps/cli/src/agent/agent-client-initialize.test.ts new file mode 100644 index 000000000..e3b96a2b8 --- /dev/null +++ b/apps/cli/src/agent/agent-client-initialize.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionId } from '@lody/shared'; +import type { Logger } from '@/utils/logger'; + +const connectionMocks = vi.hoisted(() => ({ + initialize: vi.fn(), + newSession: vi.fn(), + loadSession: vi.fn(), + resumeSession: vi.fn(), + setSessionConfigOption: vi.fn(), + unstable_forkSession: vi.fn(), + closeSession: vi.fn(), + cancel: vi.fn(), +})); + +vi.mock('@agentclientprotocol/sdk', () => ({ + PROTOCOL_VERSION: 1, + ClientSideConnection: class { + readonly initialize = connectionMocks.initialize; + readonly newSession = connectionMocks.newSession; + readonly loadSession = connectionMocks.loadSession; + readonly resumeSession = connectionMocks.resumeSession; + readonly setSessionConfigOption = connectionMocks.setSessionConfigOption; + readonly unstable_forkSession = connectionMocks.unstable_forkSession; + readonly closeSession = connectionMocks.closeSession; + readonly cancel = connectionMocks.cancel; + }, +})); + +import { AgentClient } from './agent-client'; + +function createLogger(): Logger { + const logger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + setLevel: vi.fn(), + setDebug: vi.fn(), + child: vi.fn(() => logger), + close: vi.fn(async () => undefined), + }; + return logger; +} + +function readInitializeClientCapabilitiesMeta(): unknown { + const request = connectionMocks.initialize.mock.calls[0]?.[0] as + | { clientCapabilities?: { _meta?: unknown } } + | undefined; + return request?.clientCapabilities?._meta; +} + +async function startWithIdentity(identity: { + cliType: 'builtin' | 'registry' | 'custom'; + agentType: string; +}): Promise { + const client = new AgentClient({ + logger: createLogger(), + sessionId: `session-${identity.cliType}-${identity.agentType}` as SessionId, + terminalManager: {} as never, + agentConfig: identity, + onUpdateMessage: vi.fn(), + onRequestPermission: vi.fn(), + }); + await client.startSession({} as never, '/workdir'); +} + +describe('AgentClient initialize clientCapabilities._meta', () => { + beforeEach(() => { + vi.clearAllMocks(); + connectionMocks.initialize.mockResolvedValue({ agentCapabilities: {} }); + connectionMocks.newSession.mockResolvedValue({ sessionId: 'acp-session-1' }); + }); + + it('advertises parameterizedModelPicker for registry Cursor', async () => { + await startWithIdentity({ cliType: 'registry', agentType: 'cursor' }); + + expect(readInitializeClientCapabilitiesMeta()).toEqual({ + parameterizedModelPicker: true, + }); + }); + + it('omits parameterizedModelPicker for custom Cursor', async () => { + await startWithIdentity({ cliType: 'custom', agentType: 'cursor' }); + + expect(readInitializeClientCapabilitiesMeta()).toBeUndefined(); + }); + + it('omits parameterizedModelPicker for a builtin agent', async () => { + await startWithIdentity({ cliType: 'builtin', agentType: 'claude' }); + + expect(readInitializeClientCapabilitiesMeta()).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 8aa975aa4..9c8f0ba9a 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -77,6 +77,7 @@ import { parseLodyExtensionMessage, parseRateLimitsSnapshot, } from './lody-acp-extension'; +import { isRegistryCursorAgent } from './cursor-acp'; /** * Checks if an error is a transport-related error that may be transient. @@ -213,7 +214,7 @@ function isAcpInvalidRequestError(error: unknown): boolean { ); } -function isAcpMethodNotFoundError(error: unknown): boolean { +export function isAcpMethodNotFoundError(error: unknown): boolean { return ( typeof error === 'object' && error !== null && @@ -1384,6 +1385,40 @@ export class AgentClient implements acp.Client { return {}; } + async requestExtMethod( + method: string, + params: Record = {}, + options: { signal?: AbortSignal } = {} + ): Promise> { + const connection = this.connection; + if (!connection) { + throw new Error('ACP session is not connected'); + } + options.signal?.throwIfAborted(); + const request = connection.request, Record>( + method, + params + ); + const signal = options.signal; + if (!signal) { + return request; + } + let onAbort: (() => void) | undefined; + const abortPromise = new Promise((_resolve, reject) => { + onAbort = () => { + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort); + }); + try { + return await withAbort(request, abortPromise); + } finally { + if (onAbort) { + signal.removeEventListener('abort', onAbort); + } + } + } + async extNotification?(method: string, params: Record): Promise { try { await this.handleExtensionMessage(method, params); @@ -1750,6 +1785,12 @@ export class AgentClient implements acp.Client { elicitation: { form: {}, }, + ...(isRegistryCursorAgent({ + cliType: this.options.agentConfig?.cliType, + agentType: this.options.agentConfig?.agentType, + }) + ? { _meta: { parameterizedModelPicker: true } } + : {}), }, }), startupAbort diff --git a/apps/cli/src/agent/cursor-acp.test.ts b/apps/cli/src/agent/cursor-acp.test.ts new file mode 100644 index 000000000..da6ce7d5e --- /dev/null +++ b/apps/cli/src/agent/cursor-acp.test.ts @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + CURSOR_LIST_AVAILABLE_MODELS_METHOD, + fetchCursorModelCatalog, + isRegistryCursorAgent, +} from './cursor-acp'; + +type CatalogClient = { + requestExtMethod: ReturnType; +}; + +const createCatalogClient = ( + impl: ( + method: string, + params: Record, + options: { signal?: AbortSignal } + ) => Promise> +): CatalogClient => ({ + requestExtMethod: vi.fn(impl), +}); + +const selectOption = (value: string, name: string) => ({ value, name }); + +const selectConfig = (fields: { + id: string; + name: string; + currentValue: string; + category?: string; + options: Array<{ value: string; name: string }>; +}) => ({ + type: 'select' as const, + id: fields.id, + name: fields.name, + currentValue: fields.currentValue, + ...(fields.category ? { category: fields.category } : {}), + options: fields.options, +}); + +const fullModelCatalogResponse = { + models: [ + { + value: 'model-full', + name: 'Full', + configOptions: [ + selectConfig({ + id: 'model', + name: 'Model', + category: 'model', + currentValue: 'model-full', + options: [selectOption('model-full', 'Full')], + }), + selectConfig({ + id: 'mode', + name: 'Mode', + category: 'mode', + currentValue: 'agent', + options: [selectOption('agent', 'Agent')], + }), + selectConfig({ + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + currentValue: 'true', + options: [selectOption('true', 'On'), selectOption('false', 'Off')], + }), + selectConfig({ + id: 'effort', + name: 'Effort', + category: 'thought_level', + currentValue: 'low', + options: [selectOption('low', 'Low'), selectOption('high', 'High')], + }), + selectConfig({ + id: 'fast', + name: 'Fast', + currentValue: 'false', + options: [selectOption('true', 'On'), selectOption('false', 'Off')], + }), + selectConfig({ + id: 'context', + name: 'Context', + category: 'model_config', + currentValue: 'default', + options: [selectOption('default', 'Default')], + }), + { + type: 'boolean' as const, + id: 'boolean', + name: 'Boolean', + currentValue: false, + }, + ], + }, + { + value: 'model-empty', + configOptions: [], + }, + ], +}; + +const rejectWhenAborted = (signal: AbortSignal | undefined): Promise> => + new Promise((_resolve, reject) => { + if (!signal) { + return; + } + const rejectAbort = () => { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }; + if (signal.aborted) { + rejectAbort(); + return; + } + signal.addEventListener('abort', rejectAbort, { once: true }); + }); + +describe('isRegistryCursorAgent', () => { + it('is true only for registry Cursor identity', () => { + expect(isRegistryCursorAgent({ cliType: 'registry', agentType: 'cursor' })).toBe(true); + expect(isRegistryCursorAgent({ cliType: 'custom', agentType: 'cursor' })).toBe(false); + expect(isRegistryCursorAgent({ cliType: 'builtin', agentType: 'claude' })).toBe(false); + expect(isRegistryCursorAgent({ cliType: undefined, agentType: undefined })).toBe(false); + }); +}); + +describe('fetchCursorModelCatalog', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('normalizes per-model options and drops model and mode entries', async () => { + const client = createCatalogClient(async () => fullModelCatalogResponse); + const result = await fetchCursorModelCatalog({ client }); + + expect(result).toBeDefined(); + expect(Object.keys(result ?? {})).toEqual(['model-full', 'model-empty']); + expect(result?.['model-full']?.map((option) => option.id)).toEqual([ + 'thinking', + 'effort', + 'fast', + 'context', + 'boolean', + ]); + expect( + result?.['model-full']?.some( + (option) => option.category === 'model' || option.category === 'mode' + ) + ).toBe(false); + expect(result?.['model-empty']).toEqual([]); + expect(result?.['model-absent']).toBeUndefined(); + expect(client.requestExtMethod).toHaveBeenCalledWith( + CURSOR_LIST_AVAILABLE_MODELS_METHOD, + {}, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it('returns undefined when the agent reports method not found', async () => { + const client = createCatalogClient(async () => { + throw { code: -32601, message: 'Method not found' }; + }); + + await expect(fetchCursorModelCatalog({ client })).resolves.toBeUndefined(); + }); + + it('rejects other JSON-RPC errors as incomplete with cause', async () => { + const rpcError = Object.assign(new Error('internal error'), { code: -32000 }); + const client = createCatalogClient(async () => { + throw rpcError; + }); + + await expect(fetchCursorModelCatalog({ client })).rejects.toMatchObject({ + message: expect.stringMatching(/^\[ACP_CAPABILITIES_INCOMPLETE\]/), + cause: rpcError, + }); + }); + + it('rejects a response that omits models', async () => { + const client = createCatalogClient(async () => ({})); + + await expect(fetchCursorModelCatalog({ client })).rejects.toThrow( + '[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models response failed validation' + ); + }); + + it('rejects an option with an unknown type', async () => { + const client = createCatalogClient(async () => ({ + models: [ + { + value: 'model-full', + configOptions: [ + { + type: 'slider', + id: 'temperature', + name: 'Temperature', + currentValue: '0.5', + }, + ], + }, + ], + })); + + await expect(fetchCursorModelCatalog({ client })).rejects.toThrow( + '[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models response failed validation' + ); + }); + + it('rejects a select option that is missing currentValue', async () => { + const client = createCatalogClient(async () => ({ + models: [ + { + value: 'model-full', + configOptions: [ + { + type: 'select', + id: 'thinking', + name: 'Thinking', + options: [selectOption('true', 'On')], + }, + ], + }, + ], + })); + + await expect(fetchCursorModelCatalog({ client })).rejects.toThrow( + '[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models response failed validation' + ); + }); + + it('rejects a catalog that lists the same model value twice', async () => { + const client = createCatalogClient(async () => ({ + models: [ + { value: 'model-empty', configOptions: [] }, + { value: 'model-empty', configOptions: [] }, + ], + })); + + await expect(fetchCursorModelCatalog({ client })).rejects.toThrow( + '[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models listed model model-empty more than once' + ); + }); + + it('rejects with incomplete when the catalog request times out', async () => { + vi.useFakeTimers(); + vi.spyOn(AbortSignal, 'timeout').mockImplementation((timeoutMs: number) => { + const controller = new AbortController(); + setTimeout(() => { + controller.abort(new DOMException('The operation timed out.', 'TimeoutError')); + }, timeoutMs); + return controller.signal; + }); + const client = createCatalogClient((_method, _params, options) => + rejectWhenAborted(options.signal) + ); + + const pending = fetchCursorModelCatalog({ client, timeoutMs: 5_000 }); + const assertion = expect(pending).rejects.toThrow( + '[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models timed out or was aborted' + ); + await vi.advanceTimersByTimeAsync(5_000); + await assertion; + }); + + it('rejects with incomplete when the caller aborts after the request starts', async () => { + const controller = new AbortController(); + const client = createCatalogClient((_method, _params, options) => + rejectWhenAborted(options.signal) + ); + + const pending = fetchCursorModelCatalog({ client, signal: controller.signal }); + const assertion = expect(pending).rejects.toThrow( + '[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models timed out or was aborted' + ); + controller.abort(); + await assertion; + }); + + it('rejects a pre-aborted signal before requesting the catalog', async () => { + const controller = new AbortController(); + controller.abort(); + const client = createCatalogClient(async () => fullModelCatalogResponse); + + await expect(fetchCursorModelCatalog({ client, signal: controller.signal })).rejects.toThrow(); + expect(client.requestExtMethod).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/agent/cursor-acp.ts b/apps/cli/src/agent/cursor-acp.ts new file mode 100644 index 000000000..7c47d9a68 --- /dev/null +++ b/apps/cli/src/agent/cursor-acp.ts @@ -0,0 +1,134 @@ +import type { SessionConfigOption } from '@agentclientprotocol/sdk'; +import type { AcpConfigOptionSummary, AgentConfigCliType } from '@lody/shared'; +import { z } from 'zod'; + +import { normalizeConfigOptions } from '@/agent/acp-capability-normalization'; +import { isAcpMethodNotFoundError, type AgentClient } from '@/agent/agent-client'; +import { formatErrorMessage } from '@/utils/format-error'; +import type { Logger } from '@/utils/logger'; + +export const CURSOR_LIST_AVAILABLE_MODELS_METHOD = 'cursor/list_available_models'; + +/** + * Identity, not command line, decides the opt-in: a custom or builtin config that + * happens to launch the same binary keeps standard ACP behaviour. + */ +export const isRegistryCursorAgent = (identity: { + cliType: AgentConfigCliType | null | undefined; + agentType: string | null | undefined; +}): boolean => identity.cliType === 'registry' && identity.agentType === 'cursor'; + +export type FetchCursorModelCatalogParams = { + client: Pick; + signal?: AbortSignal; + timeoutMs?: number; + logger?: Logger; +}; + +const cursorSelectOptionSchema = z.looseObject({ + value: z.string(), + name: z.string(), + description: z.string().nullable().optional(), +}); + +const cursorSelectGroupSchema = z.looseObject({ + group: z.string(), + name: z.string(), + options: z.array(cursorSelectOptionSchema), +}); + +const cursorSelectConfigOptionSchema = z.looseObject({ + type: z.literal('select'), + id: z.string().min(1), + name: z.string(), + description: z.string().nullable().optional(), + category: z.string().nullable().optional(), + currentValue: z.string(), + options: z.array(z.union([cursorSelectOptionSchema, cursorSelectGroupSchema])), +}); + +const cursorBooleanConfigOptionSchema = z.looseObject({ + type: z.literal('boolean'), + id: z.string().min(1), + name: z.string(), + description: z.string().nullable().optional(), + category: z.string().nullable().optional(), + currentValue: z.boolean(), +}); + +const cursorConfigOptionSchema = z.discriminatedUnion('type', [ + cursorSelectConfigOptionSchema, + cursorBooleanConfigOptionSchema, +]); + +const cursorListAvailableModelsResponseSchema = z.looseObject({ + models: z.array( + z.looseObject({ + value: z.string().min(1), + name: z.string().optional(), + configOptions: z.array(cursorConfigOptionSchema), + }) + ), +}); + +const INCOMPLETE_PREFIX = '[ACP_CAPABILITIES_INCOMPLETE]'; + +/** + * Fetches registry Cursor's per-model option catalog via `cursor/list_available_models`. + * JSON-RPC `-32601` means the method is absent and returns `undefined`. + * Any other failure, including validation, timeout, or abort, throws `[ACP_CAPABILITIES_INCOMPLETE]`. + * Options whose category is `model` or `mode` are dropped after normalization. + */ +export async function fetchCursorModelCatalog( + params: FetchCursorModelCatalogParams +): Promise | undefined> { + const { client, signal, timeoutMs = 15_000, logger } = params; + signal?.throwIfAborted(); + const combined = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]); + try { + const raw = await client.requestExtMethod( + CURSOR_LIST_AVAILABLE_MODELS_METHOD, + {}, + { signal: combined } + ); + const parsed = cursorListAvailableModelsResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `${INCOMPLETE_PREFIX} cursor/list_available_models response failed validation: ${parsed.error.message}` + ); + } + const configOptionsByModel: Record = {}; + for (const entry of parsed.data.models) { + if (Object.hasOwn(configOptionsByModel, entry.value)) { + throw new Error( + `${INCOMPLETE_PREFIX} cursor/list_available_models listed model ${entry.value} more than once` + ); + } + const normalized = + // Parsed configOptions match the ACP SessionConfigOption shape. + normalizeConfigOptions(entry.configOptions as SessionConfigOption[]) ?? []; + configOptionsByModel[entry.value] = normalized.filter( + (option) => option.category !== 'model' && option.category !== 'mode' + ); + } + return configOptionsByModel; + } catch (error) { + if (isAcpMethodNotFoundError(error)) { + logger?.debug(`cursor/list_available_models is unavailable: ${formatErrorMessage(error)}`); + return undefined; + } + if (combined.aborted) { + throw new Error( + `${INCOMPLETE_PREFIX} cursor/list_available_models timed out or was aborted`, + { cause: error } + ); + } + if (error instanceof Error && error.message.startsWith(INCOMPLETE_PREFIX)) { + throw error; + } + throw new Error( + `${INCOMPLETE_PREFIX} cursor/list_available_models failed: ${formatErrorMessage(error)}`, + { cause: error } + ); + } +} diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 5d97136ca..609f1fa59 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -7,6 +7,7 @@ import { getMachineFlockDocId, getSessionRoomId, type AcpCapabilityCacheEntry, + type AcpConfigOptionSummary, type AgentConfigMeta, type LocalProjectGitState, type MachineId, @@ -142,6 +143,108 @@ const createAcpCapability = (): AcpCapabilityCacheEntry => ({ fetchedAt: 1, }); +const cursorThinkingSelect = (): AcpConfigOptionSummary => ({ + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorEffortSelect = ( + values: readonly string[], + currentValue: string +): AcpConfigOptionSummary => ({ + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue, + options: values.map((value) => ({ value, name: value })), +}); + +const cursorFastSelect = (): AcpConfigOptionSummary => ({ + id: 'fast', + name: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorContextSelect = (): AcpConfigOptionSummary => ({ + id: 'context', + name: 'Context', + category: 'model_config', + type: 'select', + currentValue: 'default', + options: [{ value: 'default', name: 'Default' }], +}); + +const createCursorAcpCapability = (): AcpCapabilityCacheEntry => ({ + cliType: 'custom', + agentType: 'cursor', + modes: [], + models: [], + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'opus', + options: [ + { value: 'opus', name: 'Opus' }, + { value: 'sonnet', name: 'Sonnet' }, + { value: 'gemini', name: 'Gemini' }, + { value: 'gpt', name: 'GPT' }, + ], + }, + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), + ], + configOptionsByModel: { + opus: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), + ], + sonnet: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'max'], 'medium'), + ], + gemini: [cursorEffortSelect(['minimal', 'low', 'medium', 'high'], 'medium')], + gpt: [ + { + id: 'reasoning', + name: 'Reasoning', + category: 'thought_level', + type: 'select', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + { value: 'extra-high', name: 'Extra high' }, + ], + }, + cursorFastSelect(), + ], + empty: [], + }, + fetchedAt: 1, +}); + describe('session command helpers', () => { it('uses one hard Meta read across request validation and accepted create materialization', async () => { const syncMetaOrThrow = vi.fn(async () => undefined); @@ -545,6 +648,48 @@ describe('session command helpers', () => { ).not.toThrow(); }); + it('validates turn config option values against the target model catalog', () => { + const capability = createCursorAcpCapability(); + expect(() => + validateTurnConfigOptionValues({ effort: 'max' }, capability, undefined, 'gemini') + ).toThrow(/Allowed values/); + expect(() => + validateTurnConfigOptionValues({ fast: 'true' }, capability, undefined, 'sonnet') + ).toThrow('Unknown ACP config option for the selected agent: fast.'); + expect(() => + validateTurnConfigOptionValues({ thinking: 'true' }, capability, undefined, 'sonnet') + ).not.toThrow(); + }); + + it('drops inherited options that the explicit target model catalog rejects', () => { + expect( + filterCompatibleInheritedTurnConfig( + { modelId: 'opus', configOptionValues: { fast: 'true', effort: 'xhigh' } }, + createCursorAcpCapability(), + { targetModelId: 'sonnet' } + ) + ).toEqual({ modelId: 'opus' }); + }); + + it('filters inherited options against the inherited model when no create target is given', () => { + expect( + filterCompatibleInheritedTurnConfig( + { modelId: 'sonnet', configOptionValues: { fast: 'true' } }, + createCursorAcpCapability() + ) + ).toEqual({ modelId: 'sonnet' }); + }); + + it('removes a superseded inherited model option when create names a different model', () => { + expect( + filterCompatibleInheritedTurnConfig( + { configOptionValues: { model: 'opus', thinking: 'true' } }, + createCursorAcpCapability(), + { targetModelId: 'sonnet' } + ) + ).toEqual({ configOptionValues: { thinking: 'true' } }); + }); + it('sorts sessions with invalid createdAt timestamps deterministically', () => { const sessions = [ createSessionMeta({ diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 198bf0d26..192e52e84 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -49,6 +49,8 @@ import { isMachineDocRoomId, isSessionDocRoomId, hasAgentRunConfigSelection, + resolveAcpConfigOptionsForModel, + resolveAcpTargetModelId, resolveAgentRunConfigSelection, resolveBaseBranchPreference, resolveProjectGitHubRepo, @@ -1474,16 +1476,25 @@ export function validateTurnConfigOptionValues( * capability's `configOptions` only describe the probed model, so re-checking * them here would reject values that are valid for the target model. */ - skipIds?: ReadonlySet + skipIds?: ReadonlySet, + modelId?: string | null ): void { const entries = Object.entries(values ?? {}).filter(([id]) => !skipIds?.has(id)); if (entries.length === 0) { return; } - if (!capability?.configOptions) { + const targetModelId = resolveAcpTargetModelId({ + modelId, + configOptionValues: values, + configOptions: capability?.configOptions, + }); + const configOptions = capability + ? resolveAcpConfigOptionsForModel(capability, targetModelId) + : undefined; + if (!configOptions) { throw new Error('ACP config options are unavailable for the selected agent.'); } - const optionsById = new Map(capability.configOptions.map((option) => [option.id, option])); + const optionsById = new Map(configOptions.map((option) => [option.id, option])); for (const [id, value] of entries) { const option = optionsById.get(id); if (!option) { @@ -1498,12 +1509,21 @@ export function validateTurnConfigOptionValues( export function filterCompatibleTurnConfigOptionValues( values: Record | undefined, - capability: AcpCapabilityCacheEntry | undefined + capability: AcpCapabilityCacheEntry | undefined, + targetModelId?: string | null ): Record | undefined { - if (!values || !capability?.configOptions) { + const resolvedTargetModelId = resolveAcpTargetModelId({ + modelId: targetModelId, + configOptionValues: values, + configOptions: capability?.configOptions, + }); + const configOptions = capability + ? resolveAcpConfigOptionsForModel(capability, resolvedTargetModelId) + : undefined; + if (!values || !configOptions) { return undefined; } - const optionsById = new Map(capability.configOptions.map((option) => [option.id, option])); + const optionsById = new Map(configOptions.map((option) => [option.id, option])); const compatible = Object.fromEntries( Object.entries(values).filter(([id, value]) => { const option = optionsById.get(id); @@ -1547,18 +1567,58 @@ export function validateTurnModeAndModel( } } +/** Drop a superseded inherited `model` option so a Turn does not name two models. */ +function dropSupersededInheritedModelOption( + values: Record | undefined, + capability: AcpCapabilityCacheEntry | undefined, + explicitModelId: string | null | undefined +): Record | undefined { + if (!values) { + return undefined; + } + if (typeof explicitModelId !== 'string' || explicitModelId === '') { + return values; + } + const modelOption = capability?.configOptions?.find( + (option) => option.category === 'model' && option.type === 'select' + ); + if (modelOption === undefined) { + return values; + } + const inheritedModel = values[modelOption.id]; + if (typeof inheritedModel !== 'string' || inheritedModel === explicitModelId) { + return values; + } + const next = { ...values }; + delete next[modelOption.id]; + return Object.keys(next).length > 0 ? next : undefined; +} + export function filterCompatibleInheritedTurnConfig( config: ResolvedTurnDispatchConfig | undefined, - capability: AcpCapabilityCacheEntry | undefined + capability: AcpCapabilityCacheEntry | undefined, + options?: { targetModelId?: string | null } ): ResolvedTurnDispatchConfig | undefined { if (!config) { return undefined; } const supportedModes = getSupportedTurnSelectorIds(capability, 'mode'); const supportedModels = getSupportedTurnSelectorIds(capability, 'model'); - const configOptionValues = filterCompatibleTurnConfigOptionValues( - config.configOptionValues, - capability + const inheritedModelId = + typeof config.modelId === 'string' && supportedModels.has(config.modelId) + ? config.modelId + : undefined; + const targetModelId = + options?.targetModelId || + inheritedModelId || + resolveAcpTargetModelId({ + configOptionValues: config.configOptionValues, + configOptions: capability?.configOptions, + }); + const configOptionValues = dropSupersededInheritedModelOption( + filterCompatibleTurnConfigOptionValues(config.configOptionValues, capability, targetModelId), + capability, + options?.targetModelId ); return { ...(config.modeId && supportedModes.has(config.modeId) ? { modeId: config.modeId } : {}), @@ -2777,13 +2837,16 @@ async function resolveEffectiveSessionCreateDispatchConfig(args: { validateTurnConfigOptionValues( requested.config.configOptionValues, capability, - requested.validatedConfigIds + requested.validatedConfigIds, + requested.config.modelId ); return { ...withBuiltinDefaultTurnMode( mergeTurnDispatchConfig( requested.config, - filterCompatibleInheritedTurnConfig(inheritedDispatchConfig, capability) + filterCompatibleInheritedTurnConfig(inheritedDispatchConfig, capability, { + targetModelId: requested.config.modelId, + }) ), args.agentConfig ), @@ -3153,7 +3216,12 @@ export async function sendSessionChatResult( agentConfigId: session.agentConfigId, }); validateTurnModeAndModel(dispatchConfig, capability); - validateTurnConfigOptionValues(dispatchConfig.configOptionValues, capability); + validateTurnConfigOptionValues( + dispatchConfig.configOptionValues, + capability, + undefined, + dispatchConfig.modelId + ); } const effectiveDispatchConfig = withBuiltinDefaultTurnMode(dispatchConfig, session); diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 9d6a9ce7f..eac69c8db 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -1522,7 +1522,7 @@ export class LoroDocumentManager { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; configOptionsByModel?: Record } = {} ): Promise { options.signal?.throwIfAborted(); if (!this.machine) { @@ -3120,6 +3120,7 @@ const serializeAcpCapabilityWithoutFetchTime = (entry: AcpCapabilityCacheEntry): models: entry.models, configOptions: entry.configOptions, modelReasoningEfforts: entry.modelReasoningEfforts, + configOptionsByModel: entry.configOptionsByModel, availableCommands: entry.availableCommands, sessionFork: entry.sessionFork, acknowledgedSteer: entry.acknowledgedSteer, @@ -3208,7 +3209,7 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; configOptionsByModel?: Record } = {} ): Promise { options.signal?.throwIfAborted(); const normalizedModes = modes.map((mode) => ({ @@ -3221,6 +3222,19 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { name: model.name ?? model.modelId, description: model.description ?? undefined, })); + const handle = await this.openMachineFlockDoc(); + options.signal?.throwIfAborted(); + const capabilityKey = getAcpCapabilityCacheKey(configId); + const existing = getMachineFlockAcpCapabilities( + readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) + )[capabilityKey]; + // omitted keeps the stored catalog for the same sourceVersion + const configOptionsByModel = + options.configOptionsByModel !== undefined + ? options.configOptionsByModel + : existing && existing.sourceVersion === sourceVersion + ? existing.configOptionsByModel + : undefined; const entry: AcpCapabilityCacheEntry = { cliType, agentType, @@ -3239,13 +3253,8 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { ? modelReasoningEfforts : undefined, fetchedAt: getServerNow(), + ...(configOptionsByModel !== undefined ? { configOptionsByModel } : {}), }; - const handle = await this.openMachineFlockDoc(); - options.signal?.throwIfAborted(); - const capabilityKey = getAcpCapabilityCacheKey(configId); - const existing = getMachineFlockAcpCapabilities( - readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) - )[capabilityKey]; if ( existing && serializeAcpCapabilityWithoutFetchTime(existing) === diff --git a/apps/cli/src/lib/loro/machine-document-capabilities.test.ts b/apps/cli/src/lib/loro/machine-document-capabilities.test.ts index a9254d48f..28d5fd28c 100644 --- a/apps/cli/src/lib/loro/machine-document-capabilities.test.ts +++ b/apps/cli/src/lib/loro/machine-document-capabilities.test.ts @@ -1,9 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + type AcpConfigOptionSummary, type AgentConfigId, + getAcpCapabilityCacheKey, + getMachineFlockAcpCapabilities, type MachineFlockKey, type MachineFlockWritableFlock, type MachineId, + readMachineFlockRowsFromFlock, type WorkspaceId, } from '@lody/shared'; import type { LoroRepo } from 'loro-repo'; @@ -169,4 +173,124 @@ describe('MachineDocument ACP capabilities', () => { expect(flock.commits).toBe(0); expect(flush).not.toHaveBeenCalled(); }); + + const createCapabilityDocument = () => { + const flock = new FakeMachineFlock(); + const flush = vi.fn(async () => undefined); + const syncOnce = vi.fn(async () => undefined); + const markDirty = vi.fn(); + const repo = { + openFlockDoc: vi.fn(async () => ({ flock, syncOnce })), + flush, + } as unknown as LoroRepo; + const document = new MachineDocument( + repo, + 'workspace-1' as WorkspaceId, + 'machine-1' as MachineId, + markDirty + ); + return { document, flock, flush, markDirty, syncOnce }; + }; + + const readStoredCapability = (flock: FakeMachineFlock) => + getMachineFlockAcpCapabilities( + readMachineFlockRowsFromFlock(flock, { families: ['acpCapability'] }) + )[getAcpCapabilityCacheKey('config-1' as AgentConfigId)]; + + const catalogOption: AcpConfigOptionSummary = { + id: 'fast', + name: 'Fast', + type: 'select', + currentValue: 'false', + options: [ + { value: 'true', name: 'On' }, + { value: 'false', name: 'Off' }, + ], + }; + + const catalog: Record = { + 'gpt-5': [catalogOption], + composer: [], + }; + + const writeCapabilities = ( + document: MachineDocument, + options: { + sourceVersion?: string; + configOptionsByModel?: Record; + } = {} + ) => + document.updateAcpCapabilities( + 'config-1' as AgentConfigId, + 'builtin', + 'codex', + [{ id: 'agent', name: 'Agent' }], + [{ modelId: 'gpt-5', name: 'GPT-5' }], + undefined, + [{ name: '/help', description: 'Help' }], + false, + options.sourceVersion ?? 'builtin:codex:test', + undefined, + true, + 'configOptionsByModel' in options + ? { configOptionsByModel: options.configOptionsByModel } + : {} + ); + + it('persists configOptionsByModel including a model mapped to an empty list', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + + expect(readStoredCapability(flock)?.configOptionsByModel).toEqual(catalog); + }); + + it('preserves stored configOptionsByModel when a later same-sourceVersion write omits it', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + await writeCapabilities(document); + + expect(readStoredCapability(flock)?.configOptionsByModel).toEqual(catalog); + }); + + it('drops stored configOptionsByModel when sourceVersion changes and the write omits it', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + await writeCapabilities(document, { sourceVersion: 'builtin:codex:next' }); + + const stored = readStoredCapability(flock); + expect(stored?.sourceVersion).toBe('builtin:codex:next'); + expect(stored).not.toHaveProperty('configOptionsByModel'); + }); + + it('does not skip a catalog-only change and skips an identical catalog rewrite', async () => { + const { document, flock, flush, markDirty } = createCapabilityDocument(); + + await writeCapabilities(document); + expect(flock.commits).toBe(1); + expect(readStoredCapability(flock)).not.toHaveProperty('configOptionsByModel'); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + expect(flock.commits).toBe(2); + expect(flush).toHaveBeenCalledTimes(2); + expect(markDirty).toHaveBeenCalledTimes(2); + expect(readStoredCapability(flock)?.configOptionsByModel).toEqual(catalog); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + expect(flock.commits).toBe(2); + expect(flush).toHaveBeenCalledTimes(2); + expect(markDirty).toHaveBeenCalledTimes(2); + expect(readStoredCapability(flock)?.configOptionsByModel).toEqual(catalog); + }); + + it('replaces a stored catalog when configOptionsByModel is an explicit empty object', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + await writeCapabilities(document, { configOptionsByModel: {} }); + + expect(readStoredCapability(flock)?.configOptionsByModel).toEqual({}); + }); }); diff --git a/apps/cli/src/session/acp-session-config-applier.test.ts b/apps/cli/src/session/acp-session-config-applier.test.ts index 0c2978021..31a7e530b 100644 --- a/apps/cli/src/session/acp-session-config-applier.test.ts +++ b/apps/cli/src/session/acp-session-config-applier.test.ts @@ -1,9 +1,69 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ACPSessionId, SessionId } from '@lody/shared'; +import type { ACPSessionId, AcpConfigOptionValue, SessionId } from '@lody/shared'; import type { AgentClient } from '@/agent/agent-client'; import type { Logger } from '@/utils/logger'; import { applyAcpSessionRunConfig } from './acp-session-config-applier'; +type SessionConfigCall = { + method: 'unstable_setSessionModel' | 'setSessionConfigOption'; + configId?: string; + value: AcpConfigOptionValue; +}; + +const CURSOR_STYLE_PARAMETER_OPTIONS = [ + { + id: 'model', + category: 'model', + type: 'select', + currentValue: 'model-a', + options: [ + { name: 'Model A', value: 'model-a' }, + { name: 'Model B', value: 'model-b' }, + ], + }, + { + id: 'thinking', + category: 'thought_level', + type: 'select', + currentValue: 'false', + options: [ + { name: 'On', value: 'true' }, + { name: 'Off', value: 'false' }, + ], + }, + { + id: 'fast', + type: 'select', + currentValue: 'false', + options: [ + { name: 'On', value: 'true' }, + { name: 'Off', value: 'false' }, + ], + }, +] as const; + +function createOrderedAgentClient(args?: { + setSessionModel?: (sessionId: ACPSessionId, modelId: string) => Promise; +}): { agentClient: AgentClient; calls: SessionConfigCall[] } { + const calls: SessionConfigCall[] = []; + const agentClient = { + isCreated: () => true, + getConfigOptions: () => [...CURSOR_STYLE_PARAMETER_OPTIONS], + unstable_setSessionModel: async (sessionId: ACPSessionId, modelId: string) => { + calls.push({ method: 'unstable_setSessionModel', value: modelId }); + await args?.setSessionModel?.(sessionId, modelId); + }, + setSessionConfigOption: async ( + _sessionId: ACPSessionId, + configId: string, + value: AcpConfigOptionValue + ) => { + calls.push({ method: 'setSessionConfigOption', configId, value }); + }, + } as unknown as AgentClient; + return { agentClient, calls }; +} + function createLogger(): Logger { const logger = { debug: vi.fn(), @@ -227,4 +287,115 @@ describe('applyAcpSessionRunConfig', () => { runtimeConfigPatch: { acpSessionId: 'acp-5', configOptionValues: {} }, }); }); + + it('applies a config-option model before per-model options even when the model key is last', async () => { + const { agentClient, calls } = createOrderedAgentClient(); + + await applyAcpSessionRunConfig({ + session: { + sessionId: 'session-6' as SessionId, + acpSessionId: 'acp-6' as ACPSessionId, + agentClient, + }, + config: { + configOptionValues: { + thinking: 'true', + fast: 'true', + model: 'model-b', + }, + }, + logger: createLogger(), + }); + + const firstCall = calls[0]; + expect(firstCall).toEqual({ method: 'unstable_setSessionModel', value: 'model-b' }); + const optionCalls = calls.filter((call) => call.method === 'setSessionConfigOption'); + const modelCallIndex = calls.findIndex((call) => call.method === 'unstable_setSessionModel'); + expect( + optionCalls.every((call) => { + const callIndex = calls.indexOf(call); + return callIndex > modelCallIndex && call.configId !== 'model'; + }) + ).toBe(true); + expect(optionCalls.filter((call) => call.configId === 'thinking')).toEqual([ + { method: 'setSessionConfigOption', configId: 'thinking', value: 'true' }, + ]); + expect(optionCalls.filter((call) => call.configId === 'fast')).toEqual([ + { method: 'setSessionConfigOption', configId: 'fast', value: 'true' }, + ]); + }); + + it('applies an explicit modelId once before per-model options and does not resend the model option', async () => { + const { agentClient, calls } = createOrderedAgentClient(); + + await applyAcpSessionRunConfig({ + session: { + sessionId: 'session-7' as SessionId, + acpSessionId: 'acp-7' as ACPSessionId, + agentClient, + }, + config: { + modelId: 'model-b', + configOptionValues: { + model: 'model-b', + thinking: 'true', + }, + }, + logger: createLogger(), + }); + + expect(calls.filter((call) => call.method === 'unstable_setSessionModel')).toEqual([ + { method: 'unstable_setSessionModel', value: 'model-b' }, + ]); + expect(calls[0]).toEqual({ method: 'unstable_setSessionModel', value: 'model-b' }); + const thinkingCallIndex = calls.findIndex( + (call) => call.method === 'setSessionConfigOption' && call.configId === 'thinking' + ); + expect(thinkingCallIndex).toBeGreaterThan(0); + expect(calls[thinkingCallIndex]).toEqual({ + method: 'setSessionConfigOption', + configId: 'thinking', + value: 'true', + }); + expect( + calls.some((call) => call.method === 'setSessionConfigOption' && call.configId === 'model') + ).toBe(false); + }); + + it('keeps a failed config-option model switch debug-only and still applies remaining options', async () => { + const { agentClient, calls } = createOrderedAgentClient({ + setSessionModel: async () => { + throw new Error('model switch rejected'); + }, + }); + + await expect( + applyAcpSessionRunConfig({ + session: { + sessionId: 'session-8' as SessionId, + acpSessionId: 'acp-8' as ACPSessionId, + agentClient, + }, + config: { + configOptionValues: { + thinking: 'true', + fast: 'true', + model: 'model-b', + }, + }, + logger: createLogger(), + }) + ).resolves.toMatchObject({ + rejectedSelections: [], + warningSelections: [], + }); + + expect(calls.filter((call) => call.method === 'unstable_setSessionModel')).toEqual([ + { method: 'unstable_setSessionModel', value: 'model-b' }, + ]); + expect(calls.filter((call) => call.method === 'setSessionConfigOption')).toEqual([ + { method: 'setSessionConfigOption', configId: 'thinking', value: 'true' }, + { method: 'setSessionConfigOption', configId: 'fast', value: 'true' }, + ]); + }); }); diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index 164240551..f32aa9190 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -159,6 +159,19 @@ export async function applyAcpSessionRunConfig(args: { `[${sessionId}] Failed to set ACP model ${JSON.stringify(config.modelId)}: ${String(error)}` ); } + } else if (typeof configOptionModelId === 'string') { + // The agent validates per-model options against the current model, so the model switch goes first. + try { + await agentClient.unstable_setSessionModel?.(acpSessionId, configOptionModelId); + confirmedLegacyModelId = configOptionModelId; + } catch (error) { + logger.debug( + `[${sessionId}] Failed to set ACP model option ${modelConfigId}=${formatAcpConfigValueForLog( + modelConfigId, + configOptionModelId + )}: ${String(error)}` + ); + } } for (const [configId, value] of configOptionEntries) { @@ -179,19 +192,6 @@ export async function applyAcpSessionRunConfig(args: { continue; } if (configId === modelConfigId) { - if (!config.modelId && typeof value === 'string') { - try { - await agentClient.unstable_setSessionModel?.(acpSessionId, value); - confirmedLegacyModelId = value; - } catch (error) { - logger.debug( - `[${sessionId}] Failed to set ACP model option ${configId}=${formatAcpConfigValueForLog( - configId, - value - )}: ${String(error)}` - ); - } - } continue; } if (shouldSkipFableFastModeDisable({ modelId: targetModelId, configId, value })) { diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 4ca91a1d9..2708f042e 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -501,6 +501,7 @@ export type SessionExecutionServiceDeps = { modes: NonNullable; models: NonNullable; configOptions?: AcpConfigOptionSummary[]; + configOptionsByModel?: Record; availableCommands?: AcpCommandSummary[]; sessionFork: boolean; acknowledgedSteer: boolean; @@ -5170,6 +5171,7 @@ export class SessionExecutionService { modes, models, configOptions, + configOptionsByModel, availableCommands, sessionFork, acknowledgedSteer, @@ -5213,7 +5215,10 @@ export class SessionExecutionService { }), modelReasoningEfforts, acknowledgedSteer, - { signal: options.signal } + { + signal: options.signal, + ...(configOptionsByModel !== undefined ? { configOptionsByModel } : {}), + } ); return { diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 33de3e8e8..f22f754b6 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -31,6 +31,32 @@ export const ACP_THOUGHT_LEVEL_CATEGORY = 'thought_level'; export const ACP_CONFIG_OPTION_ON_VALUE = 'on'; export const ACP_CONFIG_OPTION_OFF_VALUE = 'off'; +const ACP_CONFIG_OPTION_TRUE_VALUE = 'true'; +const ACP_CONFIG_OPTION_FALSE_VALUE = 'false'; + +export const isAcpOnOffSelectValues = (values: readonly string[]): boolean => + values.includes(ACP_CONFIG_OPTION_ON_VALUE) && values.includes(ACP_CONFIG_OPTION_OFF_VALUE); + +/** cursor-agent emits boolean parameters as a two-row select with these exact values. */ +export const isAcpTrueFalseSelectValues = (values: readonly string[]): boolean => + values.length === 2 && + values.includes(ACP_CONFIG_OPTION_TRUE_VALUE) && + values.includes(ACP_CONFIG_OPTION_FALSE_VALUE); + +export const isAcpToggleSelectValues = (values: readonly string[]): boolean => + isAcpOnOffSelectValues(values) || isAcpTrueFalseSelectValues(values); + +export const isAcpToggleSelectEnabledValue = (value: unknown): boolean => + value === ACP_CONFIG_OPTION_ON_VALUE || value === ACP_CONFIG_OPTION_TRUE_VALUE; + +export const toggleAcpSelectOptionValue = (values: readonly string[], enabled: boolean): string => + isAcpTrueFalseSelectValues(values) + ? enabled + ? ACP_CONFIG_OPTION_TRUE_VALUE + : ACP_CONFIG_OPTION_FALSE_VALUE + : enabled + ? ACP_CONFIG_OPTION_ON_VALUE + : ACP_CONFIG_OPTION_OFF_VALUE; /** Permission mode id that means "plan without editing" across builtin agents. */ export const ACP_PLAN_PERMISSION_MODE_ID = 'plan'; @@ -65,7 +91,8 @@ export type AgentRunConfigSelection = { export type AgentRunConfigCapabilities = { /** * `reasoningEffortValues` is per model when the agent publishes that - * breakdown; otherwise it is absent and only the snapshot below applies. + * breakdown or a per-model option catalog; otherwise it is absent and only + * the snapshot below applies. */ models: Array<{ id: string; name: string; reasoningEffortValues?: string[] }>; /** @@ -101,7 +128,7 @@ export type AgentRunConfigResolution = { type RunConfigCapabilitySource = Pick< AcpCapabilityCacheEntry, - 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' + 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' | 'configOptionsByModel' >; /** @@ -146,13 +173,12 @@ const findConfigOption = ( predicate: (option: AcpConfigOptionSummary) => boolean ): AcpConfigOptionSummary | undefined => capability?.configOptions?.find(predicate); -const isOnOffSelect = (option: AcpConfigOptionSummary): boolean => - option.type === 'select' && - option.options.some((value) => value.value === ACP_CONFIG_OPTION_ON_VALUE) && - option.options.some((value) => value.value === ACP_CONFIG_OPTION_OFF_VALUE); +const selectOptionValues = (option: AcpConfigOptionSummary): readonly string[] => + option.options.map((value) => value.value); const isToggleOption = (option: AcpConfigOptionSummary): boolean => - option.type === 'boolean' || isOnOffSelect(option); + option.type === 'boolean' || + (option.type === 'select' && isAcpToggleSelectValues(selectOptionValues(option))); const isCollaborationModeSelect = (option: AcpConfigOptionSummary): boolean => option.type === 'select' && @@ -162,24 +188,54 @@ const isCollaborationModeSelect = (option: AcpConfigOptionSummary): boolean => const toggleValue = (option: AcpConfigOptionSummary, enabled: boolean): AcpConfigOptionValue => option.type === 'boolean' ? enabled - : enabled - ? ACP_CONFIG_OPTION_ON_VALUE - : ACP_CONFIG_OPTION_OFF_VALUE; + : toggleAcpSelectOptionValue(selectOptionValues(option), enabled); + +const isMultiLevelReasoningEffortSelect = (option: AcpConfigOptionSummary): boolean => + option.type === 'select' && + isAcpThoughtLevelConfigOption(option) && + !isAcpToggleSelectValues(selectOptionValues(option)); + +const isThoughtLevelSelect = (option: AcpConfigOptionSummary): boolean => + option.type === 'select' && isAcpThoughtLevelConfigOption(option); + +/** + * A model that publishes both a thinking toggle and an effort ladder (Cursor) + * maps `reasoningEffort` onto the ladder; a model whose only thought control is + * the toggle (Kimi without effort levels) keeps mapping onto it. + */ +const findReasoningEffortOptionIn = ( + configOptions: readonly AcpConfigOptionSummary[] | undefined +): AcpConfigOptionSummary | undefined => + configOptions?.find(isMultiLevelReasoningEffortSelect) ?? + configOptions?.find(isThoughtLevelSelect); + +const findFastModeOptionIn = ( + configOptions: readonly AcpConfigOptionSummary[] | undefined +): AcpConfigOptionSummary | undefined => + configOptions?.find((option) => isAcpFastModeConfigId(option.id) && isToggleOption(option)); + +const hasPerModelCatalogEntry = ( + capability: RunConfigCapabilitySource | undefined, + modelId: string | undefined +): boolean => { + const catalog = capability?.configOptionsByModel; + return catalog !== undefined && typeof modelId === 'string' && catalog[modelId] !== undefined; +}; const findFastModeOption = ( - capability: RunConfigCapabilitySource | undefined + capability: RunConfigCapabilitySource | undefined, + targetModelId?: string ): AcpConfigOptionSummary | undefined => - findConfigOption( - capability, - (option) => isAcpFastModeConfigId(option.id) && isToggleOption(option) + findFastModeOptionIn( + capability ? resolveAcpConfigOptionsForModel(capability, targetModelId) : undefined ); const findReasoningEffortOption = ( - capability: RunConfigCapabilitySource | undefined + capability: RunConfigCapabilitySource | undefined, + targetModelId?: string ): AcpConfigOptionSummary | undefined => - findConfigOption( - capability, - (option) => option.type === 'select' && isAcpThoughtLevelConfigOption(option) + findReasoningEffortOptionIn( + capability ? resolveAcpConfigOptionsForModel(capability, targetModelId) : undefined ); const findPlanModeOption = ( @@ -249,8 +305,20 @@ export const summarizeAgentRunConfigCapabilities = ( const measuredForModelId = findCurrentModelId(capability); return { models: listModels(capability).map((model) => { - const efforts = perModelEfforts?.[model.id]; - return { ...model, ...(efforts ? { reasoningEffortValues: efforts } : {}) }; + const legacyEfforts = perModelEfforts?.[model.id]; + if (legacyEfforts) { + return { ...model, reasoningEffortValues: legacyEfforts }; + } + if (!capability || !hasPerModelCatalogEntry(capability, model.id)) { + return model; + } + const catalogEfforts = findReasoningEffortOptionIn( + resolveAcpConfigOptionsForModel(capability, model.id) + )?.options.map((value) => value.value); + return { + ...model, + ...(catalogEfforts ? { reasoningEffortValues: catalogEfforts } : {}), + }; }), reasoningEffortValues: (findReasoningEffortOption(capability)?.options ?? []).map( (value) => value.value @@ -301,7 +369,7 @@ export const resolveAgentRunConfigSelection = ( let modeId: string | undefined; if (selection.reasoningEffort !== undefined) { - const option = findReasoningEffortOption(capability); + const option = findReasoningEffortOption(capability, targetModelId); const targetModelEfforts = targetModelId ? capability.modelReasoningEfforts?.[targetModelId] : undefined; @@ -318,6 +386,14 @@ export const resolveAgentRunConfigSelection = ( // Validated against the target model; the caller's snapshot check knows // only the probed model's list and could reject a legitimate value. validatedConfigIds.push(configId); + } else if (option && hasPerModelCatalogEntry(capability, targetModelId)) { + const allowed = option.options.map((value) => value.value); + if (!allowed.includes(selection.reasoningEffort)) { + throw new Error( + `Invalid reasoning effort for model ${targetModelId}: ${selection.reasoningEffort}. Allowed values: ${allowed.join(', ')}.` + ); + } + validatedConfigIds.push(configId); } else if (switchesModel) { unverifiedSelections.push(`reasoningEffort=${selection.reasoningEffort}`); validatedConfigIds.push(configId); @@ -326,14 +402,12 @@ export const resolveAgentRunConfigSelection = ( } if (selection.fastMode !== undefined) { - const option = findFastModeOption(capability); + const option = findFastModeOption(capability, targetModelId); if (!option) { throw new Error('The selected agent does not offer a fast mode option.'); } configOptionValues[option.id] = toggleValue(option, selection.fastMode); - if (switchesModel) { - // Agents drop the fast toggle entirely for models that lack fast support, - // and no agent publishes which models those are. + if (switchesModel && !hasPerModelCatalogEntry(capability, targetModelId)) { unverifiedSelections.push(`fastMode=${selection.fastMode}`); } } @@ -359,3 +433,64 @@ export const resolveAgentRunConfigSelection = ( ...(unverifiedSelections.length > 0 ? { unverifiedSelections } : {}), }; }; + +const isModelOrModeCategory = (option: AcpConfigOptionSummary): boolean => + option.category === 'model' || option.category === 'mode'; + +/** + * Compose the option list a model should see from the probe snapshot and the + * per-model catalog. + * + * An option owned by ANY model's catalog entry is per-model and is dropped from + * the shared snapshot, so a probe-time `fast` does not leak into a model whose + * entry lacks it. `model`/`mode`-category options are always taken from the + * snapshot and never from a catalog entry, so a catalog cannot shrink the model + * picker or replace the permission mode list. + */ +export const resolveAcpConfigOptionsForModel = ( + entry: Pick, + modelId: string | null | undefined +): AcpConfigOptionSummary[] | undefined => { + const catalog = entry.configOptionsByModel; + if (catalog === undefined || typeof modelId !== 'string') { + return entry.configOptions; + } + const catalogEntry = catalog[modelId]; + if (catalogEntry === undefined) { + return entry.configOptions; + } + + const perModelIds = new Set(); + for (const options of Object.values(catalog)) { + for (const option of options) { + if (!isModelOrModeCategory(option)) { + perModelIds.add(option.id); + } + } + } + + const shared = (entry.configOptions ?? []).filter((option) => !perModelIds.has(option.id)); + const perModel = catalogEntry.filter((option) => !isModelOrModeCategory(option)); + return [...shared, ...perModel]; +}; + +export const resolveAcpTargetModelId = (args: { + modelId?: string | null; + configOptionValues?: Record; + configOptions?: AcpConfigOptionSummary[]; +}): string | undefined => { + if (typeof args.modelId === 'string' && args.modelId !== '') { + return args.modelId; + } + const modelOption = args.configOptions?.find( + (option) => option.category === 'model' && option.type === 'select' + ); + if (modelOption === undefined) { + return undefined; + } + const selected = args.configOptionValues?.[modelOption.id]; + if (typeof selected === 'string' && selected !== '') { + return selected; + } + return typeof modelOption.currentValue === 'string' ? modelOption.currentValue : undefined; +}; diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 905a432a1..41900156c 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -305,6 +305,14 @@ export type AcpCapabilityCacheEntry = { * `configOptions` is a snapshot that only describes `currentValue`'s model. */ modelReasoningEfforts?: Record; + /** + * Non-model config options each advertised model exposes, keyed by the + * `model` option value. Only an explicit capability probe of an agent that + * publishes a whole-catalog method fills it; ACP sessions never do. A model + * with no model-dependent options maps to `[]`, while a missing key means the + * catalog does not know that model. Absent when the agent exposes no catalog. + */ + configOptionsByModel?: Record; /** Available slash commands advertised by the agent. */ availableCommands?: AcpCommandSummary[]; /** True only when the runtime initialize response advertised `sessionCapabilities.fork`. */ diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 2f219b785..715008add 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -2,9 +2,16 @@ import { describe, expect, it } from 'vitest'; import { deriveModelReasoningEffortsFromLegacyModelIds, + isAcpOnOffSelectValues, + isAcpToggleSelectEnabledValue, + isAcpTrueFalseSelectValues, + resolveAcpConfigOptionsForModel, + resolveAcpTargetModelId, resolveAgentRunConfigSelection, summarizeAgentRunConfigCapabilities, + toggleAcpSelectOptionValue, type AcpCapabilityCacheEntry, + type AcpConfigOptionSummary, } from '../src'; /** Codex-shaped agent: reasoning effort, a boolean fast toggle, and collaboration mode. */ @@ -352,4 +359,517 @@ describe('agent run config selection', () => { }); expect(resolveAgentRunConfigSelection({ planMode: true }, legacy)).toEqual({ modeId: 'plan' }); }); + + it('recognises a true/false fast select and writes those advertised values', () => { + const capability: AcpCapabilityCacheEntry = { + cliType: 'custom', + agentType: 'cursor', + modes: [], + models: [], + configOptions: [ + { + id: 'fast', + name: 'Fast', + type: 'select', + currentValue: 'false', + options: [ + { value: 'true', name: 'On' }, + { value: 'false', name: 'Off' }, + ], + }, + ], + fetchedAt: 1, + }; + + expect(summarizeAgentRunConfigCapabilities(capability).fastMode).toBe(true); + expect(resolveAgentRunConfigSelection({ fastMode: true }, capability)).toEqual({ + configOptionValues: { fast: 'true' }, + }); + expect(resolveAgentRunConfigSelection({ fastMode: false }, capability)).toEqual({ + configOptionValues: { fast: 'false' }, + }); + }); +}); + +const modelSelect = (currentValue = 'a'): AcpConfigOptionSummary => ({ + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue, + options: [ + { value: 'a', name: 'A' }, + { value: 'b', name: 'B' }, + { value: 'c', name: 'C' }, + ], +}); + +const modeSelect = (): AcpConfigOptionSummary => ({ + id: 'mode', + name: 'Mode', + category: 'mode', + type: 'select', + currentValue: 'agent', + options: [{ value: 'agent', name: 'Agent' }], +}); + +const thinkingSelect = (): AcpConfigOptionSummary => ({ + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'false', + options: [ + { value: 'true', name: 'On' }, + { value: 'false', name: 'Off' }, + ], +}); + +const effortSelect = (): AcpConfigOptionSummary => ({ + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + ], +}); + +const fastSelect = (): AcpConfigOptionSummary => ({ + id: 'fast', + name: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'true', name: 'On' }, + { value: 'false', name: 'Off' }, + ], +}); + +const reasoningSelect = (): AcpConfigOptionSummary => ({ + id: 'reasoning', + name: 'Reasoning', + category: 'thought_level', + type: 'select', + currentValue: 'minimal', + options: [ + { value: 'minimal', name: 'Minimal' }, + { value: 'full', name: 'Full' }, + ], +}); + +const contextSelect = (): AcpConfigOptionSummary => ({ + id: 'context', + name: 'Context', + type: 'select', + currentValue: 'default', + options: [{ value: 'default', name: 'Default' }], +}); + +const catalogCompositionEntry = (): Pick< + AcpCapabilityCacheEntry, + 'configOptions' | 'configOptionsByModel' +> => ({ + configOptions: [modelSelect(), modeSelect(), thinkingSelect(), effortSelect(), fastSelect()], + configOptionsByModel: { + a: [thinkingSelect(), effortSelect(), fastSelect()], + b: [reasoningSelect(), contextSelect()], + c: [], + }, +}); + +describe('resolveAcpConfigOptionsForModel', () => { + it('composes shared snapshot options with model a catalog entries', () => { + const entry = catalogCompositionEntry(); + expect(resolveAcpConfigOptionsForModel(entry, 'a')?.map((option) => option.id)).toEqual([ + 'model', + 'mode', + 'thinking', + 'effort', + 'fast', + ]); + }); + + it('does not leak probe-time per-model options into model b', () => { + const entry = catalogCompositionEntry(); + expect(resolveAcpConfigOptionsForModel(entry, 'b')?.map((option) => option.id)).toEqual([ + 'model', + 'mode', + 'reasoning', + 'context', + ]); + }); + + it('keeps only shared snapshot options for a known model with an empty catalog entry', () => { + const entry = catalogCompositionEntry(); + expect(resolveAcpConfigOptionsForModel(entry, 'c')?.map((option) => option.id)).toEqual([ + 'model', + 'mode', + ]); + }); + + it('returns the snapshot unchanged for a model the catalog does not know', () => { + const entry = catalogCompositionEntry(); + expect(resolveAcpConfigOptionsForModel(entry, 'z')).toBe(entry.configOptions); + }); + + it('returns the snapshot when the catalog is absent or the model id is not a string', () => { + const entry = catalogCompositionEntry(); + const snapshot = entry.configOptions; + expect(resolveAcpConfigOptionsForModel({ configOptions: snapshot }, 'a')).toBe(snapshot); + expect(resolveAcpConfigOptionsForModel(entry, undefined)).toBe(snapshot); + expect(resolveAcpConfigOptionsForModel(entry, null)).toBe(snapshot); + }); + + it('ignores model and mode options that a catalog entry tries to replace', () => { + const snapshotModel = modelSelect(); + const snapshotMode = modeSelect(); + const entry = { + configOptions: [snapshotModel, snapshotMode, thinkingSelect(), effortSelect(), fastSelect()], + configOptionsByModel: { + a: [ + { + ...modelSelect('a'), + options: [{ value: 'a', name: 'A' }], + }, + { + ...modeSelect(), + currentValue: 'catalog-mode', + options: [{ value: 'catalog-mode', name: 'Catalog mode' }], + }, + thinkingSelect(), + effortSelect(), + fastSelect(), + ], + b: [reasoningSelect(), contextSelect()], + c: [], + }, + }; + + const resolved = resolveAcpConfigOptionsForModel(entry, 'a'); + expect(resolved?.[0]).toBe(snapshotModel); + expect(resolved?.[1]).toBe(snapshotMode); + expect(resolved?.[0]?.options.map((option) => option.value)).toEqual(['a', 'b', 'c']); + expect(resolved?.[1]?.currentValue).toBe('agent'); + expect(resolved?.map((option) => option.id)).toEqual([ + 'model', + 'mode', + 'thinking', + 'effort', + 'fast', + ]); + }); +}); + +describe('resolveAcpTargetModelId', () => { + it('prefers an explicit model id over config values and the current value', () => { + expect( + resolveAcpTargetModelId({ + modelId: 'explicit', + configOptionValues: { model: 'from-values' }, + configOptions: [modelSelect('from-current')], + }) + ).toBe('explicit'); + }); + + it('reads the model-category select from config option values before currentValue', () => { + expect( + resolveAcpTargetModelId({ + configOptionValues: { model: 'from-values' }, + configOptions: [modelSelect('from-current')], + }) + ).toBe('from-values'); + expect(resolveAcpTargetModelId({ configOptions: [modelSelect('from-current')] })).toBe( + 'from-current' + ); + }); + + it('treats an empty-string modelId as not explicit', () => { + expect( + resolveAcpTargetModelId({ + modelId: '', + configOptionValues: { model: 'from-values' }, + configOptions: [modelSelect('from-current')], + }) + ).toBe('from-values'); + }); + + it('ignores a non-string model option value', () => { + expect( + resolveAcpTargetModelId({ + configOptionValues: { model: true }, + configOptions: [modelSelect('from-current')], + }) + ).toBe('from-current'); + }); + + it('treats an empty-string model option value as not selected', () => { + expect( + resolveAcpTargetModelId({ + configOptionValues: { model: '' }, + configOptions: [modelSelect('from-current')], + }) + ).toBe('from-current'); + }); +}); + +describe('ACP toggle select predicates', () => { + it('recognises on/off selects that include extra values', () => { + expect(isAcpOnOffSelectValues(['off', 'on', 'auto'])).toBe(true); + }); + + it('recognises exactly the true/false select set', () => { + expect(isAcpTrueFalseSelectValues(['true', 'false'])).toBe(true); + expect(isAcpTrueFalseSelectValues(['false', 'true'])).toBe(true); + expect(isAcpTrueFalseSelectValues(['true', 'false', 'auto'])).toBe(false); + expect(isAcpTrueFalseSelectValues(['true', 'true'])).toBe(false); + expect(isAcpTrueFalseSelectValues(['True', 'False'])).toBe(false); + }); + + it('writes the advertised toggle representation', () => { + expect(toggleAcpSelectOptionValue(['true', 'false'], true)).toBe('true'); + expect(toggleAcpSelectOptionValue(['on', 'off'], false)).toBe('off'); + }); + + it('treats only the advertised enabled strings as on', () => { + expect(isAcpToggleSelectEnabledValue('true')).toBe(true); + expect(isAcpToggleSelectEnabledValue('on')).toBe(true); + expect(isAcpToggleSelectEnabledValue('false')).toBe(false); + expect(isAcpToggleSelectEnabledValue(true)).toBe(false); + }); +}); + +const cursorThinkingSelect = (): AcpConfigOptionSummary => ({ + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorEffortSelect = ( + values: readonly string[], + currentValue: string +): AcpConfigOptionSummary => ({ + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue, + options: values.map((value) => ({ value, name: value })), +}); + +const cursorFastSelect = (): AcpConfigOptionSummary => ({ + id: 'fast', + name: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorContextSelect = (): AcpConfigOptionSummary => ({ + id: 'context', + name: 'Context', + category: 'model_config', + type: 'select', + currentValue: 'default', + options: [{ value: 'default', name: 'Default' }], +}); + +const cursorReasoningSelect = (): AcpConfigOptionSummary => ({ + id: 'reasoning', + name: 'Reasoning', + category: 'thought_level', + type: 'select', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + { value: 'extra-high', name: 'Extra high' }, + ], +}); + +const cursorModelSelect = (): AcpConfigOptionSummary => ({ + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'opus', + options: [ + { value: 'opus', name: 'Opus' }, + { value: 'sonnet', name: 'Sonnet' }, + { value: 'gemini', name: 'Gemini' }, + { value: 'gpt', name: 'GPT' }, + { value: 'empty', name: 'Empty' }, + ], +}); + +const cursorOpusSnapshotOptions = (): AcpConfigOptionSummary[] => [ + cursorModelSelect(), + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), +]; + +const cursorCatalogCapability = (): AcpCapabilityCacheEntry => ({ + cliType: 'custom', + agentType: 'cursor', + modes: [], + models: [], + configOptions: cursorOpusSnapshotOptions(), + configOptionsByModel: { + opus: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), + ], + sonnet: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'max'], 'medium'), + ], + gemini: [cursorEffortSelect(['minimal', 'low', 'medium', 'high'], 'medium')], + gpt: [cursorReasoningSelect(), cursorFastSelect()], + empty: [], + }, + fetchedAt: 1, +}); + +describe('per-model config catalog run config', () => { + it('writes gpt extra-high onto reasoning and marks that id pre-validated', () => { + const resolved = resolveAgentRunConfigSelection( + { modelId: 'gpt', reasoningEffort: 'extra-high' }, + cursorCatalogCapability() + ); + expect(resolved.configOptionValues).toEqual({ reasoning: 'extra-high' }); + expect(resolved.configOptionValues).not.toHaveProperty('thinking'); + expect(resolved.validatedConfigIds).toEqual(['reasoning']); + }); + + it('rejects a reasoning effort the target model catalog does not allow', () => { + expect(() => + resolveAgentRunConfigSelection( + { modelId: 'gemini', reasoningEffort: 'max' }, + cursorCatalogCapability() + ) + ).toThrow('Invalid reasoning effort for model gemini'); + }); + + it('rejects fast mode when the target catalog omits it and accepts gpt without unverifiedSelections', () => { + expect(() => + resolveAgentRunConfigSelection( + { modelId: 'sonnet', fastMode: true }, + cursorCatalogCapability() + ) + ).toThrow('does not offer a fast mode option'); + + const resolved = resolveAgentRunConfigSelection( + { modelId: 'gpt', fastMode: true }, + cursorCatalogCapability() + ); + expect(resolved.configOptionValues).toEqual({ fast: 'true' }); + expect(resolved.unverifiedSelections).toBeUndefined(); + }); + + it('rejects reasoning effort when the target catalog entry is empty', () => { + expect(() => + resolveAgentRunConfigSelection( + { modelId: 'empty', reasoningEffort: 'high' }, + cursorCatalogCapability() + ) + ).toThrow('does not offer a reasoning effort option'); + }); + + it('keeps snapshot-based unverified selections when the agent has no catalog', () => { + const capability: AcpCapabilityCacheEntry = { + ...cursorCatalogCapability(), + configOptionsByModel: undefined, + }; + const resolved = resolveAgentRunConfigSelection( + { modelId: 'sonnet', reasoningEffort: 'high', fastMode: true }, + capability + ); + expect(resolved.configOptionValues).toEqual({ effort: 'high', fast: 'true' }); + expect(resolved.unverifiedSelections).toEqual(['reasoningEffort=high', 'fastMode=true']); + expect(resolved.validatedConfigIds).toEqual(['effort']); + }); + + it('maps reasoning effort onto a lone thinking toggle when no effort ladder exists', () => { + const capability: AcpCapabilityCacheEntry = { + cliType: 'builtin', + agentType: 'kimi', + modes: [], + models: [], + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'k2', + options: [{ value: 'k2', name: 'K2' }], + }, + { + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'off', + options: [ + { value: 'off', name: 'Off' }, + { value: 'on', name: 'On' }, + ], + }, + ], + fetchedAt: 1, + }; + expect( + resolveAgentRunConfigSelection({ reasoningEffort: 'on' }, capability).configOptionValues + ).toEqual({ thinking: 'on' }); + expect(summarizeAgentRunConfigCapabilities(capability).reasoningEffortValues).toEqual([ + 'off', + 'on', + ]); + }); + + it('publishes per-model catalog efforts and still prefers legacy modelReasoningEfforts', () => { + const summary = summarizeAgentRunConfigCapabilities(cursorCatalogCapability()); + expect(summary.models.find((model) => model.id === 'gpt')?.reasoningEffortValues).toEqual([ + 'low', + 'medium', + 'high', + 'extra-high', + ]); + expect(summary.models.find((model) => model.id === 'gemini')?.reasoningEffortValues).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + ]); + expect( + summary.models.find((model) => model.id === 'empty')?.reasoningEffortValues + ).toBeUndefined(); + + const withLegacy = summarizeAgentRunConfigCapabilities({ + ...cursorCatalogCapability(), + modelReasoningEfforts: { gpt: ['low', 'high'] }, + }); + expect(withLegacy.models.find((model) => model.id === 'gpt')?.reasoningEffortValues).toEqual([ + 'low', + 'high', + ]); + }); });