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/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 8ad032ae6..bee42af30 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) { @@ -3119,6 +3119,7 @@ const serializeAcpCapabilityWithoutFetchTime = (entry: AcpCapabilityCacheEntry): modes: entry.modes, models: entry.models, configOptions: entry.configOptions, + configOptionsByModel: entry.configOptionsByModel, availableCommands: entry.availableCommands, sessionFork: entry.sessionFork, acknowledgedSteer: entry.acknowledgedSteer, @@ -3207,7 +3208,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) => ({ @@ -3220,6 +3221,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, @@ -3238,13 +3252,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 98351541c..57fb7f9de 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'; @@ -129,4 +133,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 c2327af57..2b107d728 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; @@ -5169,6 +5170,7 @@ export class SessionExecutionService { modes, models, configOptions, + configOptionsByModel, availableCommands, sessionFork, acknowledgedSteer, @@ -5211,7 +5213,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..4963a8aac 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -31,6 +31,35 @@ 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'; @@ -146,13 +175,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,9 +190,7 @@ 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 findFastModeOption = ( capability: RunConfigCapabilitySource | undefined @@ -359,3 +385,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 960b9bf4b..949643688 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -307,6 +307,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..3c368d939 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,285 @@ 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); + }); });