From 0df65983d0c3a6fe27f67a93a35a94900c28b8c1 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 16:42:58 +0800 Subject: [PATCH 1/5] feat(shared): resolve ACP config options per model An ACP probe snapshot only describes the model that was current when the probe ran, while an agent such as cursor-agent publishes a distinct option set for every model. AcpCapabilityCacheEntry gains configOptionsByModel, the per-model catalog an explicit probe may store, where a model mapped to an empty list has no model-dependent options and a missing key means the catalog does not know that model. resolveAcpConfigOptionsForModel is the one composition rule for a model's options: the snapshot options no catalog entry owns plus the selected model's entry, falling back to the snapshot for an unknown model. model and mode options always come from the snapshot, so a catalog can neither shrink the model picker nor replace the permission modes. resolveAcpTargetModelId names the model a run config targets. Fast mode now also recognises a select whose values are exactly true and false, the shape cursor-agent uses for boolean parameters, and writes back the advertised representation instead of on/off. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- packages/shared/src/acp-run-config.ts | 103 ++++++- packages/shared/src/ai.ts | 8 + packages/shared/tests/acp-run-config.test.ts | 288 +++++++++++++++++++ 3 files changed, 391 insertions(+), 8 deletions(-) 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 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..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); + }); }); From 52b6b2ee406c136b87081e0cee18c9a21f9015d5 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 16:43:13 +0800 Subject: [PATCH 2/5] feat(cli): cache Cursor per-model config options from capability probes Registry Cursor now declares clientCapabilities._meta.parameterizedModelPicker at initialize, so probes and sessions see clean model ids plus per-model thinking, effort, context, and fast options instead of exploded variant strings whose in-session model switch silently fails. The gate is registry identity, never a same-named custom or builtin config. A session/new snapshot only describes the model current at probe time, so an explicit machine/acp-capabilities-refresh additionally calls the agent's cursor/list_available_models once and stores every model's options as AcpCapabilityCacheEntry.configOptionsByModel. That method is the only extension cursor-agent serves and performs no writes, unlike enumerating models through session/set_config_option, which rewrites the user's global Cursor config. Real sessions never fetch the catalog; their snapshot write keeps the stored catalog for the same sourceVersion, and the unchanged-entry comparison includes it so a refreshed catalog is committed. JSON-RPC -32601 means no catalog; a response that fails validation or lists a model twice, a timeout, or any other failure fails the probe with [ACP_CAPABILITIES_INCOMPLETE] so the settings test button can retry. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- apps/cli/src/agent/AGENTS.md | 16 + apps/cli/src/agent/acp-capabilities.test.ts | 51 ++++ apps/cli/src/agent/acp-capabilities.ts | 16 +- .../src/agent/agent-client-initialize.test.ts | 95 ++++++ apps/cli/src/agent/agent-client.ts | 43 ++- apps/cli/src/agent/cursor-acp.test.ts | 287 ++++++++++++++++++ apps/cli/src/agent/cursor-acp.ts | 134 ++++++++ apps/cli/src/lib/loro/doc.ts | 25 +- .../machine-document-capabilities.test.ts | 124 ++++++++ .../src/session/session-execution-service.ts | 7 +- 10 files changed, 784 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/agent/agent-client-initialize.test.ts create mode 100644 apps/cli/src/agent/cursor-acp.test.ts create mode 100644 apps/cli/src/agent/cursor-acp.ts 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 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/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 { From 3f627604d4c9c94ec9348e51d182c02c6ef5eb8a Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 16:43:26 +0800 Subject: [PATCH 3/5] fix(cli): switch the ACP model before applying per-model options Registry and custom agents carry the selected model in the model config option rather than modelId, and the applier switched it inside the option loop at its key position. Cursor validates thinking, effort, and fast against the model that is current when each option arrives, so options ordered before the model key were checked against the previous model and rejected. Apply the config-option model right after the explicit modelId path and skip its loop entry; the unstable_setSessionModel channel and its fallback are unchanged. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- .../acp-session-config-applier.test.ts | 173 +++++++++++++++++- .../src/session/acp-session-config-applier.ts | 26 +-- 2 files changed, 185 insertions(+), 14 deletions(-) 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 })) { From cb408ea4f7f97314e932e93546350c351bcc8db5 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 17:08:28 +0800 Subject: [PATCH 4/5] feat(shared): map MCP run config onto the target model's option catalog lody_session_create takes reasoningEffort and fastMode semantically, and the mapping onto an agent's option ids read only the probe snapshot: for a Cursor target it could write a reasoning level into the two-valued thinking select, reject fast mode for a model whose own catalog offers it, and publish the probed model's effort values for every model. The mapping now reads the target model's composed options: reasoning effort binds to a multi-level thought_level select when the model has one and to a lone thinking toggle otherwise, a value is validated against the target model's catalog entry and returned in validatedConfigIds, fast mode is looked up per model, and lody_session_create_options publishes each catalogued model's effort values. Agents without a catalog keep the snapshot-based unverified-selection path. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- packages/shared/src/acp-run-config.ts | 90 +++++-- packages/shared/tests/acp-run-config.test.ts | 232 +++++++++++++++++++ 2 files changed, 301 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 4963a8aac..f22f754b6 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -49,10 +49,7 @@ export const isAcpToggleSelectValues = (values: readonly string[]): boolean => 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 => +export const toggleAcpSelectOptionValue = (values: readonly string[], enabled: boolean): string => isAcpTrueFalseSelectValues(values) ? enabled ? ACP_CONFIG_OPTION_TRUE_VALUE @@ -94,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[] }>; /** @@ -130,7 +128,7 @@ export type AgentRunConfigResolution = { type RunConfigCapabilitySource = Pick< AcpCapabilityCacheEntry, - 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' + 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' | 'configOptionsByModel' >; /** @@ -192,20 +190,52 @@ const toggleValue = (option: AcpConfigOptionSummary, enabled: boolean): AcpConfi ? enabled : 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 = ( @@ -275,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 @@ -327,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; @@ -344,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); @@ -352,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}`); } } diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 3c368d939..715008add 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -641,3 +641,235 @@ describe('ACP toggle select predicates', () => { 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', + ]); + }); +}); From 301f03bc71e6f97375040b753fa180cb097296df Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 17:08:40 +0800 Subject: [PATCH 5/5] fix(cli): validate and inherit turn config options per target model CLI and MCP create/chat validated configOptionValues against the probe snapshot, so a Cursor turn targeting another model was checked against the probed model's options: a value the target model allows could be rejected as unknown, a value it lacks could pass, and inherited create defaults kept a parent's opus-only fast or effort for a child that will run sonnet. validateTurnConfigOptionValues and filterCompatibleTurnConfigOptionValues now compose the target model's options through resolveAcpConfigOptionsForModel, inherited defaults are filtered against the MERGED target model (explicit create modelId, then the inherited modelId, then the model option), and an explicit create modelId drops a parent's superseded model option so the frozen Turn names one model. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- apps/cli/AGENTS.md | 7 +- apps/cli/src/commands/session.test.ts | 145 ++++++++++++++++++++++++++ apps/cli/src/commands/session.ts | 94 ++++++++++++++--- 3 files changed, 232 insertions(+), 14 deletions(-) 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/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);