From 0df65983d0c3a6fe27f67a93a35a94900c28b8c1 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 16:42:58 +0800 Subject: [PATCH 1/9] 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/9] 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/9] 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 fdbec635950a318edffd949987d76f2373818e38 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 08:07:52 +0800 Subject: [PATCH 4/9] fix(shared): accept configOptionsByModel in the capability wire schema `main` returns the stored capability entry from `machine/acp-capabilities-refresh` (#395), but `AcpCapabilityCacheEntrySchema` is strict and did not declare `configOptionsByModel`. A registry Cursor probe that stored a per-model catalog was therefore rejected by the Streams RPC parser (`null`, surfaced as a refresh timeout) and threw in the local CLI `agent-config refresh` parser, although the Machine Flock write had succeeded. Declare the optional field; the schema stays strict. Model: claude-fable-5.1 --- packages/shared/src/message-schemas.ts | 1 + packages/shared/tests/message-schemas.test.ts | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index b5ed18473..45af64470 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -1214,6 +1214,7 @@ const AcpCapabilityCacheEntrySchema = z models: z.array(AcpModelSchema), configOptions: z.array(AcpConfigOptionSummarySchema).optional(), modelReasoningEfforts: z.record(z.string(), z.array(z.string())).optional(), + configOptionsByModel: z.record(z.string(), z.array(AcpConfigOptionSummarySchema)).optional(), availableCommands: z .array( z diff --git a/packages/shared/tests/message-schemas.test.ts b/packages/shared/tests/message-schemas.test.ts index 3d695f0a8..d4be2cba9 100644 --- a/packages/shared/tests/message-schemas.test.ts +++ b/packages/shared/tests/message-schemas.test.ts @@ -314,6 +314,86 @@ describe('message-schemas machine ACP capabilities refresh', () => { response.capability ); }); + + it('accepts a capability whose configOptionsByModel carries the per-model catalog', () => { + const configOptionsByModel = { + 'claude-opus-4-7': [ + { + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'true', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], + }, + ], + 'gemini-3.1-pro': [], + }; + const response = { + type: 'machine/acp-capabilities-refresh_response', + machineId: 'machine-1', + configId: 'config-1', + cliType: 'registry', + agentType: 'deepseek', + success: true, + capability: { + cliType: 'registry', + agentType: 'deepseek', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime', + sourceVersion: 'registry:deepseek:test', + modes: [], + models: [{ modelId: 'kimi-k3', name: 'Kimi K3' }], + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'kimi-k3', + options: [{ value: 'kimi-k3', name: 'Kimi K3' }], + }, + ], + modelReasoningEfforts: { 'kimi-k3': ['low', 'high', 'max'] }, + configOptionsByModel, + sessionFork: false, + fetchedAt: 1, + }, + }; + + expect(MachineAcpCapabilitiesRefreshResponseSchema.safeParse(response).success).toBe(true); + expect( + MachineAcpCapabilitiesRefreshResponseSchema.parse(response).capability?.configOptionsByModel + ).toEqual(configOptionsByModel); + }); + + it('still rejects unknown capability fields', () => { + const response = { + type: 'machine/acp-capabilities-refresh_response', + machineId: 'machine-1', + configId: 'config-1', + cliType: 'registry', + agentType: 'deepseek', + success: true, + capability: { + cliType: 'registry', + agentType: 'deepseek', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime', + sourceVersion: 'registry:deepseek:test', + modes: [], + models: [{ modelId: 'kimi-k3', name: 'Kimi K3' }], + catalogByModel: {}, + sessionFork: false, + fetchedAt: 1, + }, + }; + + expect(MachineAcpCapabilitiesRefreshResponseSchema.safeParse(response).success).toBe(false); + }); }); describe('message-schemas machine ACP authentication', () => { From 01d6d3dfe6a7b7bc9379e543508c2b5e77667522 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 08:07:52 +0800 Subject: [PATCH 5/9] fix(cli): invalidate pre-picker registry Cursor capability rows Opting registry Cursor into `parameterizedModelPicker` changes the model ids the agent advertises, but a row probed before the opt-in kept `ACP_CAPABILITY_CACHE_VERSION` and the same `cursor@` source version, so every client still treated it as authoritative and offered exploded variant ids the agent now rejects. Registry Cursor's capability source version now ends in `CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX`, and `isAcpCapabilityCacheEntryCurrent` rejects a registry Cursor row without it: pre-opt-in rows become `unavailable` until the first session or refresh rewrites them, other agents are untouched, and the cache version stays 6. `isRegistryCursorAgent` moves to `@lody/shared` so the identity gate, the suffix, and the current-ness predicate are one binding. Model: claude-fable-5.1 --- apps/cli/src/agent/AGENTS.md | 9 +- apps/cli/src/agent/acp-capabilities.ts | 3 +- apps/cli/src/agent/agent-client.ts | 2 +- apps/cli/src/agent/cursor-acp.test.ts | 15 +--- apps/cli/src/agent/cursor-acp.ts | 11 +-- apps/cli/src/agent/setting.ts | 7 +- apps/cli/tests/agent-setting.test.ts | 30 ++++++- packages/shared/src/ai.ts | 31 ++++++- .../shared/tests/acp-capability-cache.test.ts | 90 +++++++++++++++++++ 9 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 packages/shared/tests/acp-capability-cache.test.ts diff --git a/apps/cli/src/agent/AGENTS.md b/apps/cli/src/agent/AGENTS.md index 81d96e825..00bfd180d 100644 --- a/apps/cli/src/agent/AGENTS.md +++ b/apps/cli/src/agent/AGENTS.md @@ -85,7 +85,14 @@ arrive: context/message-flow.md "Upstream". `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. + provider-neutral. The opt-in changes what the agent advertises, so + `getAcpCapabilitySourceVersion` appends + `CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX` to registry Cursor's + source version and `isAcpCapabilityCacheEntryCurrent` rejects a registry Cursor + row without it: rows probed before the opt-in (exploded variant ids, no catalog) + are never authoritative, and the first session or refresh rewrites them. + Predicate and suffix are one binding in `@lody/shared` `ai.ts`; never re-derive + either in the CLI. - `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 diff --git a/apps/cli/src/agent/acp-capabilities.ts b/apps/cli/src/agent/acp-capabilities.ts index a8c41ce98..c45180eab 100644 --- a/apps/cli/src/agent/acp-capabilities.ts +++ b/apps/cli/src/agent/acp-capabilities.ts @@ -3,6 +3,7 @@ import { type AgentConfigCliType, type BuiltinRuntimeOverrides, type CustomAcpLaunchSpec, + isRegistryCursorAgent, } from '@lody/shared'; import type { Logger } from '@/utils/logger'; import { shutdownLocalAcpAgent, startLocalAcpAgent } from '@/agent/acp-runner'; @@ -14,7 +15,7 @@ import { normalizeAcpSessionCapabilities, type AcpCapabilitiesResult, } from '@/agent/acp-capability-normalization'; -import { fetchCursorModelCatalog, isRegistryCursorAgent } from '@/agent/cursor-acp'; +import { fetchCursorModelCatalog } from '@/agent/cursor-acp'; export { normalizeConfigOptions } from '@/agent/acp-capability-normalization'; export type { AcpCapabilitiesResult } from '@/agent/acp-capability-normalization'; diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 9c8f0ba9a..a4eb61e65 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -36,6 +36,7 @@ import { buildAskUserQuestionElicitationResponse, formatMcpResolutionProblem, getServerNow, + isRegistryCursorAgent, } from '@lody/shared'; import { getLocalControlSocketPath } from '@lody/shared/node/local-ipc'; import { getLodyMcpHttpEndpoint } from '@/mcp/lody-mcp-http-server'; @@ -77,7 +78,6 @@ 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. diff --git a/apps/cli/src/agent/cursor-acp.test.ts b/apps/cli/src/agent/cursor-acp.test.ts index da6ce7d5e..454a42261 100644 --- a/apps/cli/src/agent/cursor-acp.test.ts +++ b/apps/cli/src/agent/cursor-acp.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - CURSOR_LIST_AVAILABLE_MODELS_METHOD, - fetchCursorModelCatalog, - isRegistryCursorAgent, -} from './cursor-acp'; +import { CURSOR_LIST_AVAILABLE_MODELS_METHOD, fetchCursorModelCatalog } from './cursor-acp'; type CatalogClient = { requestExtMethod: ReturnType; @@ -114,15 +110,6 @@ const rejectWhenAborted = (signal: AbortSignal | undefined): Promise { - 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(); diff --git a/apps/cli/src/agent/cursor-acp.ts b/apps/cli/src/agent/cursor-acp.ts index 7c47d9a68..893a1b46c 100644 --- a/apps/cli/src/agent/cursor-acp.ts +++ b/apps/cli/src/agent/cursor-acp.ts @@ -1,5 +1,5 @@ import type { SessionConfigOption } from '@agentclientprotocol/sdk'; -import type { AcpConfigOptionSummary, AgentConfigCliType } from '@lody/shared'; +import type { AcpConfigOptionSummary } from '@lody/shared'; import { z } from 'zod'; import { normalizeConfigOptions } from '@/agent/acp-capability-normalization'; @@ -9,15 +9,6 @@ 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; diff --git a/apps/cli/src/agent/setting.ts b/apps/cli/src/agent/setting.ts index a42cb66ba..ae3faa7ab 100644 --- a/apps/cli/src/agent/setting.ts +++ b/apps/cli/src/agent/setting.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { type AgentConfigCliType, + CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX, DEEPSEEK_HARNESS_BASE_URL_ENV, type BuiltinRuntimeOverrides, type CliType, @@ -14,6 +15,7 @@ import { getRegistryAcpLaunchKind, isBuiltinAgentType, isManagedBuiltinAgentType, + isRegistryCursorAgent, REGISTRY_ACP_AGENTS, type RegistryAcpAgent, type RegistryNpxDistribution, @@ -236,7 +238,10 @@ export function getAcpCapabilitySourceVersion( return `registry:${input.agentType}:unknown`; } - return `${agent.id}@${agent.version}`; + const registrySourceVersion = `${agent.id}@${agent.version}`; + return isRegistryCursorAgent({ cliType: 'registry', agentType: agent.id }) + ? `${registrySourceVersion}${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}` + : registrySourceVersion; } export function resolveRegistryAgentACPSetting(agent: RegistryAcpAgent): ResolvedACPSetting { diff --git a/apps/cli/tests/agent-setting.test.ts b/apps/cli/tests/agent-setting.test.ts index b48d02ee7..e96e1cb26 100644 --- a/apps/cli/tests/agent-setting.test.ts +++ b/apps/cli/tests/agent-setting.test.ts @@ -8,7 +8,10 @@ import { ACP_EXTENSION_DSH_QUERY_PATH_ENV, ACP_EXTENSION_DSH_SESSION_ROOT_ENV, } from 'acp-extension-dsh/profile'; -import { REGISTRY_ACP_AGENTS } from '@lody/shared'; +import { + CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX, + REGISTRY_ACP_AGENTS, +} from '@lody/shared'; import { getAcpCapabilitySourceVersion, @@ -103,6 +106,31 @@ describe('resolveBuiltinACPSetting', () => { ); }); + it('keys registry Cursor capability versions on the parameterized model picker suffix', () => { + const cursorVersion = REGISTRY_ACP_AGENTS.find((agent) => agent.id === 'cursor')!.version; + expect(getAcpCapabilitySourceVersion({ cliType: 'registry', agentType: 'cursor' })).toBe( + `cursor@${cursorVersion}${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}` + ); + + const otherRegistryAgent = REGISTRY_ACP_AGENTS.find((agent) => agent.id !== 'cursor')!; + expect( + getAcpCapabilitySourceVersion({ + cliType: 'registry', + agentType: otherRegistryAgent.id, + }) + ).toBe(`${otherRegistryAgent.id}@${otherRegistryAgent.version}`); + + const customCursorVersion = getAcpCapabilitySourceVersion({ + cliType: 'custom', + agentType: 'cursor', + customAcp: { command: 'cursor-agent' }, + }); + expect(customCursorVersion.startsWith('custom:')).toBe(true); + expect( + customCursorVersion.endsWith(CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX) + ).toBe(false); + }); + it('launches DeepSeek Harness through the pinned ACP npm composition', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'lody-deepseek-harness-test-')); vi.stubEnv(DEEPSEEK_HARNESS_HOME_ENV, dshHome); diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 41900156c..9db297cc1 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -326,9 +326,38 @@ export type AcpCapabilityCacheEntry = { export const getAcpCapabilityCacheKey = (configId: AgentConfigId): string => configId; +/** + * Identity, not command line, decides the Cursor 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'; + +/** + * Appended to registry Cursor's capability source version once the client declares + * `parameterizedModelPicker`. Rows probed before the opt-in describe exploded variant + * model ids the agent no longer advertises and carry no per-model catalog, so a row + * without the marker is never current. + */ +export const CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX = + '+parameterized-model-picker'; + export const isAcpCapabilityCacheEntryCurrent = ( entry: AcpCapabilityCacheEntry | undefined -): entry is AcpCapabilityCacheEntry => entry?.cacheVersion === ACP_CAPABILITY_CACHE_VERSION; +): entry is AcpCapabilityCacheEntry => { + if (entry?.cacheVersion !== ACP_CAPABILITY_CACHE_VERSION) { + return false; + } + if ( + isRegistryCursorAgent(entry) && + entry.sourceVersion?.endsWith(CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX) !== true + ) { + return false; + } + return true; +}; export const isAcpCapabilityCacheEntryCurrentForRuntimeOverrides = ( entry: AcpCapabilityCacheEntry | undefined, diff --git a/packages/shared/tests/acp-capability-cache.test.ts b/packages/shared/tests/acp-capability-cache.test.ts new file mode 100644 index 000000000..2f0d75b69 --- /dev/null +++ b/packages/shared/tests/acp-capability-cache.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { + ACP_CAPABILITY_CACHE_VERSION, + CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX, + getAcpCapabilityCacheEntryAuthority, + isAcpCapabilityCacheEntryCurrent, + isRegistryCursorAgent, + type AcpCapabilityCacheEntry, +} from '../src/ai'; + +const cacheEntry = ( + fields: Pick & + Partial> +): AcpCapabilityCacheEntry => ({ + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime', + modes: [], + models: [], + fetchedAt: 1, + ...fields, +}); + +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('isAcpCapabilityCacheEntryCurrent', () => { + it('rejects a pre-opt-in registry Cursor row without the marker', () => { + const entry = cacheEntry({ + cliType: 'registry', + agentType: 'cursor', + sourceVersion: 'cursor@2026.08.31', + }); + expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(false); + expect(getAcpCapabilityCacheEntryAuthority(entry, undefined)).toBe('unavailable'); + }); + + it('rejects a registry Cursor row with no sourceVersion', () => { + const entry = cacheEntry({ + cliType: 'registry', + agentType: 'cursor', + sourceVersion: undefined, + }); + expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(false); + }); + + it('accepts a registry Cursor row that carries the marker', () => { + const entry = cacheEntry({ + cliType: 'registry', + agentType: 'cursor', + sourceVersion: `cursor@2026.08.31${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}`, + }); + expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(true); + expect(getAcpCapabilityCacheEntryAuthority(entry, undefined)).toBe('authoritative'); + }); + + it('does not require the marker for a registry non-Cursor agent', () => { + const entry = cacheEntry({ + cliType: 'registry', + agentType: 'gemini', + sourceVersion: 'gemini@1.0.0', + }); + expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(true); + }); + + it('does not require the marker for a custom Cursor agent', () => { + const entry = cacheEntry({ + cliType: 'custom', + agentType: 'cursor', + sourceVersion: 'custom:{"command":"cursor-agent"}', + }); + expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(true); + }); + + it('still rejects a marked registry Cursor row with a stale cache version', () => { + const entry = cacheEntry({ + cliType: 'registry', + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1, + sourceVersion: `cursor@2026.08.31${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}`, + }); + expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(false); + }); +}); From 078f26b93775c3497763ea03f1f06273e4608788 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 17:08:00 +0800 Subject: [PATCH 6/9] feat(components): follow per-model config options in composer selectors The composer built its selectors from the probe snapshot alone, so a Cursor session showed the probe-time model's thinking, effort, context, and fast options whichever model was selected, and a fast select whose values are true/false fell through as a generic dropdown. Selector catalogs now resolve through resolveAcpConfigOptionsForModel with the model read from the channel the composer writes: the model config option for registry and custom agents, the dedicated picker for builtin ones, and no currentValue fallback, since an unselected model composes the snapshot. The Role editor and detail pane feed the STORED value into that channel so a Role's pinned options resolve against its own model's catalog. Toggle-shaped thought_level options are their own bucket, thoughtToggleSelectors, so a two-valued thinking switch no longer occupies the Reasoning row and hides the effort ladder; the bottom bar, the Tasks menu, and Recently used keep rendering them where no toggle row exists. Fast accepts boolean, on/off, and true/false selects and writes back the advertised value. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- .../sessions/agent-role-detail-pane.tsx | 2 + .../settings/agent-role-editor-dialog.tsx | 4 + .../shared/acp-inline-selector-group.tsx | 11 +- .../components/shared/acp-selector-options.ts | 71 ++++-- .../tasks/task-agent-run-config-menu.tsx | 25 +- .../components/src/lib/acp-selector-order.ts | 17 +- .../components/src/lib/recent-run-configs.ts | 16 +- .../tests/acp-inline-selector-group.test.ts | 115 +++++++++ .../tests/acp-selector-options.test.ts | 229 ++++++++++++++++++ .../tests/agent-role-detail-pane.test.tsx | 51 ++++ 10 files changed, 507 insertions(+), 34 deletions(-) diff --git a/packages/components/src/components/sessions/agent-role-detail-pane.tsx b/packages/components/src/components/sessions/agent-role-detail-pane.tsx index e90d710ab..fcd259c7f 100644 --- a/packages/components/src/components/sessions/agent-role-detail-pane.tsx +++ b/packages/components/src/components/sessions/agent-role-detail-pane.tsx @@ -80,6 +80,8 @@ export function AgentRoleDetailPane({ agentType: agentConfig.agentType, runtimeOverrides: agentConfig.runtimeOverrides, machine: machine ?? null, + selectedModelId: role.runConfig.modelId ?? null, + configOptionValues: role.runConfig.configOptionValues, } : undefined ); diff --git a/packages/components/src/components/settings/agent-role-editor-dialog.tsx b/packages/components/src/components/settings/agent-role-editor-dialog.tsx index 116dc359d..8f7b79b1a 100644 --- a/packages/components/src/components/settings/agent-role-editor-dialog.tsx +++ b/packages/components/src/components/settings/agent-role-editor-dialog.tsx @@ -122,6 +122,10 @@ export function AgentRoleEditorDialog({ agentType: selectedAgentConfig.agentType, runtimeOverrides: selectedAgentConfig.runtimeOverrides, machine: selectedMachineId ? (machines.get(selectedMachineId) ?? null) : null, + // The STORED value names the model whose catalog composes the options; + // feeding the derived defaults back in would loop. + selectedModelId: editor?.value.modelId ?? null, + configOptionValues: editor?.value.configOptionValues, } : undefined ); diff --git a/packages/components/src/components/shared/acp-inline-selector-group.tsx b/packages/components/src/components/shared/acp-inline-selector-group.tsx index 353477d89..d90d08c76 100644 --- a/packages/components/src/components/shared/acp-inline-selector-group.tsx +++ b/packages/components/src/components/shared/acp-inline-selector-group.tsx @@ -220,8 +220,13 @@ export function AcpFooterSelectorGroup({ onConfigOptionChange, contentClassName, }: AcpFooterSelectorGroupProps) { - const { modelSelectors, thoughtLevelSelectors, booleanSelectors, otherSelectors } = - orderAcpConfigOptionSelectors(configOptionSelectors); + const { + modelSelectors, + thoughtLevelSelectors, + thoughtToggleSelectors, + booleanSelectors, + otherSelectors, + } = orderAcpConfigOptionSelectors(configOptionSelectors); return ( <> @@ -250,7 +255,7 @@ export function AcpFooterSelectorGroup({ }) ) : null} - {thoughtLevelSelectors.map((selector) => + {[...thoughtLevelSelectors, ...thoughtToggleSelectors].map((selector) => selector.type === 'select' ? renderConfigSelector(selector, { tone, diff --git a/packages/components/src/components/shared/acp-selector-options.ts b/packages/components/src/components/shared/acp-selector-options.ts index 87bc4dc82..c81b74b8a 100644 --- a/packages/components/src/components/shared/acp-selector-options.ts +++ b/packages/components/src/components/shared/acp-selector-options.ts @@ -6,12 +6,17 @@ import { ACP_COLLABORATION_MODE_PLAN_VALUE, isAcpFastModeConfigId, isAcpThoughtLevelConfigOption, + isAcpToggleSelectEnabledValue, + isAcpToggleSelectValues, getAcpCapabilityCacheKey, getAcpCapabilityCacheEntryAuthority, getBuiltinDefaultModeId, getStaticBuiltinAcpCapabilities, isAcpCapabilityCacheEntryCurrentForRuntimeOverrides, + resolveAcpConfigOptionsForModel, + toggleAcpSelectOptionValue, type AcpCapabilityAuthority, + type AcpCapabilityCacheEntry, type AgentConfigId, type AgentConfigCliType, type MachineViewMeta, @@ -83,10 +88,8 @@ const CODEX_EXTENDED_REASONING_VALUES = new Set( CODEX_EXTENDED_REASONING_OPTIONS.map((option) => option.value) ); -const isOnOffSelectSelector = (selector: AcpSelectConfigOptionSelector): boolean => { - const values = new Set(selector.options.map((option) => option.value)); - return values.has(CONFIG_OPTION_ON_VALUE) && values.has(CONFIG_OPTION_OFF_VALUE); -}; +const selectOptionValues = (selector: AcpSelectConfigOptionSelector): string[] => + selector.options.map((option) => option.value); /** * Classifies a selector as a "fast mode" toggle so it renders in the dedicated @@ -95,7 +98,8 @@ const isOnOffSelectSelector = (selector: AcpSelectConfigOptionSelector): boolean */ export const isFastModeSelector = (selector: AcpConfigOptionSelector): boolean => isAcpFastModeConfigId(selector.configId) && - (selector.type === 'boolean' || isOnOffSelectSelector(selector)); + (selector.type === 'boolean' || + (selector.type === 'select' && isAcpToggleSelectValues(selectOptionValues(selector)))); export const isOnOffConfigOptionValue = ( value: AcpConfigOptionValue | undefined @@ -107,7 +111,10 @@ export const resolveOnOffConfigOptionEnabled = ( value: AcpConfigOptionValue | undefined ): boolean => { const resolved = resolveConfigOptionValue(selector, value); - return selector.type === 'boolean' ? resolved === true : resolved === CONFIG_OPTION_ON_VALUE; + if (selector.type === 'boolean') { + return resolved === true; + } + return isAcpToggleSelectEnabledValue(resolved); }; export const toggleOnOffConfigOptionValue = ( @@ -115,11 +122,10 @@ export const toggleOnOffConfigOptionValue = ( value: AcpConfigOptionValue | undefined ): AcpConfigOptionValue => { const enabled = resolveOnOffConfigOptionEnabled(selector, value); - return selector.type === 'boolean' - ? !enabled - : enabled - ? CONFIG_OPTION_OFF_VALUE - : CONFIG_OPTION_ON_VALUE; + if (selector.type === 'boolean') { + return !enabled; + } + return toggleAcpSelectOptionValue(selectOptionValues(selector), !enabled); }; export const isConfigOptionValueValid = ( @@ -212,6 +218,42 @@ type ResolvedConfigOptions = { configOptions?: AcpConfigOptionSummary[]; }; +const isAcpProbedTarget = (target?: Pick): boolean => + target?.cliType === 'registry' || target?.cliType === 'custom'; + +/** + * The model must be read from the channel the composer writes (registry/custom write the `model` config option; a live session seeds `selectedModelId` from the stale runtime baseline). + */ +const resolveComposerTargetModelId = ( + configOptions: AcpConfigOptionSummary[], + target?: AcpSelectorTarget +): string | undefined => { + const modelOption = configOptions.find( + (option) => option.category === 'model' && option.type === 'select' + ); + const storedModel = modelOption ? target?.configOptionValues?.[modelOption.id] : undefined; + const fromConfigOption = + typeof storedModel === 'string' && storedModel !== '' ? storedModel : undefined; + const fromPicker = + typeof target?.selectedModelId === 'string' && target.selectedModelId !== '' + ? target.selectedModelId + : undefined; + if (isAcpProbedTarget(target)) { + return fromConfigOption ?? fromPicker; + } + return fromPicker; +}; + +const resolveCatalogForModel = ( + capability: Pick, + configOptions: AcpConfigOptionSummary[], + target?: AcpSelectorTarget +): AcpConfigOptionSummary[] | undefined => + resolveAcpConfigOptionsForModel( + { configOptions, configOptionsByModel: capability.configOptionsByModel }, + resolveComposerTargetModelId(configOptions, target) + ); + const resolveConfigOptions = (target?: AcpSelectorTarget): ResolvedConfigOptions => { if (!target?.cliType || !target.agentType) { return { authority: 'unavailable' }; @@ -222,8 +264,9 @@ const resolveConfigOptions = (target?: AcpSelectorTarget): ResolvedConfigOptions const capability = target.machine?.acpCapabilities?.[key]; if (isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(capability, target.runtimeOverrides)) { const authority = getAcpCapabilityCacheEntryAuthority(capability, target.runtimeOverrides); - if (capability.configOptions?.length) { - return { authority, configOptions: capability.configOptions }; + const snapshot = capability.configOptions; + if (snapshot?.length) { + return { authority, configOptions: resolveCatalogForModel(capability, snapshot, target) }; } // Fallback: synthesize configOptions from legacy modes/models. const synthesized: AcpConfigOptionSummary[] = []; @@ -500,7 +543,7 @@ export const buildAcpSelectorOptions = (target?: AcpSelectorTarget): AcpSelector // Custom providers are arbitrary ACP agents just like registry agents: their // modes/models come from the capability probe (configOptions), not the // builtin tables. - const isAcpProbed = target?.cliType === 'registry' || target?.cliType === 'custom'; + const isAcpProbed = isAcpProbedTarget(target); const modeConfigOption = configOptions?.find( (opt) => opt.category === 'mode' && opt.type === 'select' && opt.id !== 'interaction_mode' ); diff --git a/packages/components/src/components/tasks/task-agent-run-config-menu.tsx b/packages/components/src/components/tasks/task-agent-run-config-menu.tsx index 80758eb40..9165fd2cd 100644 --- a/packages/components/src/components/tasks/task-agent-run-config-menu.tsx +++ b/packages/components/src/components/tasks/task-agent-run-config-menu.tsx @@ -268,12 +268,24 @@ export function TaskAgentRunConfigMenu({ ); const modelConfigSelector = ordered.modelSelectors[0] as AcpSelectConfigOptionSelector | undefined; + // The Reasoning row binds the effort ladder when there is one; a lone thinking + // toggle keeps that row. Any other toggle-shaped thought option stays visible + // as a provider-defined select, since this menu has no dedicated toggle rows. + const thinkingSelector = useMemo( + () => + (ordered.thoughtLevelSelectors.find((s) => s.type === 'select') ?? + ordered.thoughtToggleSelectors.find((s) => s.type === 'select')) as + | AcpSelectConfigOptionSelector + | undefined, + [ordered.thoughtLevelSelectors, ordered.thoughtToggleSelectors] + ); const extraSelectSelectors = useMemo( () => - ordered.otherSelectors.filter( - (selector): selector is AcpSelectConfigOptionSelector => selector.type === 'select' + [...ordered.otherSelectors, ...ordered.thoughtToggleSelectors].filter( + (selector): selector is AcpSelectConfigOptionSelector => + selector.type === 'select' && selector !== thinkingSelector ), - [ordered.otherSelectors] + [ordered.otherSelectors, ordered.thoughtToggleSelectors, thinkingSelector] ); const modelPickerOptions = useMemo( () => (modelOptions.length > 0 ? modelOptions : (modelConfigSelector?.options ?? [])), @@ -291,13 +303,6 @@ export function TaskAgentRunConfigMenu({ const modelLabel = modelPickerOptions.find((opt) => opt.value === modelValue)?.label ?? modelValue; - const thinkingSelector = useMemo( - () => - ordered.thoughtLevelSelectors.find((s) => s.type === 'select') as - | AcpSelectConfigOptionSelector - | undefined, - [ordered.thoughtLevelSelectors] - ); const thinkingValue = thinkingSelector ? ((resolveConfigOptionValue( thinkingSelector, diff --git a/packages/components/src/lib/acp-selector-order.ts b/packages/components/src/lib/acp-selector-order.ts index df4b0255d..d0fd940d5 100644 --- a/packages/components/src/lib/acp-selector-order.ts +++ b/packages/components/src/lib/acp-selector-order.ts @@ -1,4 +1,4 @@ -import { isAcpPlanModeConfigOption } from '@lody/shared'; +import { isAcpPlanModeConfigOption, isAcpToggleSelectValues } from '@lody/shared'; import { isFastModeSelector, isThoughtLevelSelector, @@ -10,6 +10,7 @@ import { export type OrderedAcpConfigOptionSelectors = { modelSelectors: AcpSelectConfigOptionSelector[]; thoughtLevelSelectors: AcpConfigOptionSelector[]; + thoughtToggleSelectors: AcpConfigOptionSelector[]; fastModeSelectors: AcpConfigOptionSelector[]; planModeSelectors: AcpConfigOptionSelector[]; booleanSelectors: AcpBooleanConfigOptionSelector[]; @@ -19,12 +20,20 @@ export type OrderedAcpConfigOptionSelectors = { otherSelectors: AcpConfigOptionSelector[]; }; +const selectOptionValues = (selector: AcpSelectConfigOptionSelector): string[] => + selector.options.map((option) => option.value); + +const isThoughtToggleSelector = (selector: AcpConfigOptionSelector): boolean => + selector.type === 'boolean' || + (selector.type === 'select' && isAcpToggleSelectValues(selectOptionValues(selector))); + export const orderAcpConfigOptionSelectors = ( selectors: AcpConfigOptionSelector[] ): OrderedAcpConfigOptionSelectors => { const ordered: OrderedAcpConfigOptionSelectors = { modelSelectors: [], thoughtLevelSelectors: [], + thoughtToggleSelectors: [], fastModeSelectors: [], planModeSelectors: [], booleanSelectors: [], @@ -40,7 +49,11 @@ export const orderAcpConfigOptionSelectors = ( continue; } if (isThoughtLevelSelector(selector)) { - ordered.thoughtLevelSelectors.push(selector); + if (isThoughtToggleSelector(selector)) { + ordered.thoughtToggleSelectors.push(selector); + } else { + ordered.thoughtLevelSelectors.push(selector); + } continue; } if (selector.configId === 'interaction_mode' && selector.type === 'select') { diff --git a/packages/components/src/lib/recent-run-configs.ts b/packages/components/src/lib/recent-run-configs.ts index 62ff30404..4b0b64d9b 100644 --- a/packages/components/src/lib/recent-run-configs.ts +++ b/packages/components/src/lib/recent-run-configs.ts @@ -135,8 +135,13 @@ export function describeRunConfigSelection({ configOptionSelectors: ReadonlyArray; configOptionValues: Record | undefined; }): RunConfigFace { - const { modelSelectors, thoughtLevelSelectors, planModeSelectors, fastModeSelectors } = - orderAcpConfigOptionSelectors([...configOptionSelectors]); + const { + modelSelectors, + thoughtLevelSelectors, + thoughtToggleSelectors, + planModeSelectors, + fastModeSelectors, + } = orderAcpConfigOptionSelectors([...configOptionSelectors]); const modelConfigSelector = modelSelectors[0]; const pickerOptions = modelOptions.length > 0 ? modelOptions : (modelConfigSelector?.options ?? []); @@ -149,9 +154,10 @@ export function describeRunConfigSelection({ configOptionValues?.[modelConfigSelector.configId] ) as string) ?? null) : null; - const thinkingSelector = thoughtLevelSelectors.find( - (selector): selector is AcpSelectConfigOptionSelector => selector.type === 'select' - ); + const isSelect = (selector: AcpConfigOptionSelector): selector is AcpSelectConfigOptionSelector => + selector.type === 'select'; + const thinkingSelector = + thoughtLevelSelectors.find(isSelect) ?? thoughtToggleSelectors.find(isSelect); const thinkingValue = thinkingSelector ? ((resolveConfigOptionValue( thinkingSelector, diff --git a/packages/components/tests/acp-inline-selector-group.test.ts b/packages/components/tests/acp-inline-selector-group.test.ts index 10bdd2d7d..3b42433d3 100644 --- a/packages/components/tests/acp-inline-selector-group.test.ts +++ b/packages/components/tests/acp-inline-selector-group.test.ts @@ -56,9 +56,39 @@ const makeOnOffSelector = (configId: string, label: string): AcpConfigOptionSele ], }); +const makeToggleSelect = ( + configId: string, + values: readonly [string, string], + category?: string +): AcpConfigOptionSelector => ({ + configId, + label: configId, + category, + type: 'select', + currentValue: values[0], + options: [ + { value: values[0], label: values[0] }, + { value: values[1], label: values[1] }, + ], +}); + +const makeMultiSelect = ( + configId: string, + values: readonly string[], + category?: string +): AcpConfigOptionSelector => ({ + configId, + label: configId, + category, + type: 'select', + currentValue: values[0] ?? '', + options: values.map((value) => ({ value, label: value })), +}); + const mapIds = (value: OrderedAcpConfigOptionSelectors) => ({ model: value.modelSelectors.map((selector) => selector.configId), thought: value.thoughtLevelSelectors.map((selector) => selector.configId), + thoughtToggle: value.thoughtToggleSelectors.map((selector) => selector.configId), fastMode: value.fastModeSelectors.map((selector) => selector.configId), planMode: value.planModeSelectors.map((selector) => selector.configId), interactionMode: value.interactionModeSelectors.map((selector) => selector.configId), @@ -84,6 +114,7 @@ describe('orderAcpConfigOptionSelectors', () => { expect(mapIds(orderAcpConfigOptionSelectors(selectors))).toEqual({ model: ['model'], thought: ['reasoning_effort'], + thoughtToggle: [], fastMode: ['fast-mode'], planMode: ['collaboration_mode'], interactionMode: [], @@ -103,6 +134,7 @@ describe('orderAcpConfigOptionSelectors', () => { expect(mapIds(orderAcpConfigOptionSelectors(selectors))).toEqual({ model: [], thought: [], + thoughtToggle: [], fastMode: ['fast'], planMode: [], interactionMode: [], @@ -122,6 +154,7 @@ describe('orderAcpConfigOptionSelectors', () => { expect(mapIds(orderAcpConfigOptionSelectors(selectors))).toEqual({ model: [], thought: [], + thoughtToggle: [], fastMode: ['fast'], planMode: [], interactionMode: [], @@ -141,6 +174,7 @@ describe('orderAcpConfigOptionSelectors', () => { expect(mapIds(orderAcpConfigOptionSelectors(selectors))).toEqual({ model: [], thought: ['reasoning_effort'], + thoughtToggle: [], fastMode: [], planMode: [], interactionMode: [], @@ -161,6 +195,7 @@ describe('orderAcpConfigOptionSelectors', () => { expect(mapIds(orderAcpConfigOptionSelectors(selectors))).toEqual({ model: [], thought: [], + thoughtToggle: [], fastMode: [], planMode: [], interactionMode: ['interaction_mode'], @@ -170,4 +205,84 @@ describe('orderAcpConfigOptionSelectors', () => { other: [], }); }); + + it('splits Cursor thinking (true/false) from multi-level effort on the same list', () => { + const selectors = [ + makeToggleSelect('thinking', ['false', 'true'], 'thought_level'), + makeMultiSelect('effort', ['low', 'high'], 'thought_level'), + ]; + + expect(mapIds(orderAcpConfigOptionSelectors(selectors))).toEqual({ + model: [], + thought: ['effort'], + thoughtToggle: ['thinking'], + fastMode: [], + planMode: [], + interactionMode: [], + permissionMode: [], + mode: [], + boolean: [], + other: [], + }); + }); + + it('routes boolean and Kimi off/on thinking to the toggle bucket', () => { + expect( + mapIds( + orderAcpConfigOptionSelectors([ + makeSelector('thinking', 'Thinking', 'thought_level', 'boolean'), + ]) + ).thoughtToggle + ).toEqual(['thinking']); + expect( + mapIds( + orderAcpConfigOptionSelectors([makeToggleSelect('thinking', ['off', 'on'], 'thought_level')]) + ).thoughtToggle + ).toEqual(['thinking']); + }); + + it('keeps Kimi multi-level thinking and id-based reasoning_effort in the level bucket', () => { + expect( + mapIds( + orderAcpConfigOptionSelectors([ + makeMultiSelect('thinking', ['off', 'low', 'medium', 'high'], 'thought_level'), + ]) + ).thought + ).toEqual(['thinking']); + expect( + mapIds( + orderAcpConfigOptionSelectors([ + makeMultiSelect('reasoning_effort', ['low', 'medium', 'high']), + ]) + ).thought + ).toEqual(['reasoning_effort']); + }); + + it('leaves every other bucket unchanged for a mixed list that also has a thought toggle', () => { + const selectors = [ + makeSelector('verbosity', 'Verbosity'), + makeSelector('model', 'Model', 'model'), + makeToggleSelect('thinking', ['false', 'true'], 'thought_level'), + makeMultiSelect('effort', ['low', 'medium', 'high'], 'thought_level'), + makeSelector('mode', 'Mode', 'mode'), + makeSelector('permission_mode', 'Permission mode', '_permission'), + makeSelector('fast-mode', 'Fast Mode', undefined, 'boolean'), + makeCollaborationModeSelector(), + makeSelector('safe_mode', 'Safe Mode', undefined, 'boolean'), + makeSelector('temperature', 'Temperature'), + ]; + + expect(mapIds(orderAcpConfigOptionSelectors(selectors))).toEqual({ + model: ['model'], + thought: ['effort'], + thoughtToggle: ['thinking'], + fastMode: ['fast-mode'], + planMode: ['collaboration_mode'], + interactionMode: [], + permissionMode: ['permission_mode'], + mode: ['mode'], + boolean: ['safe_mode'], + other: ['verbosity', 'temperature'], + }); + }); }); diff --git a/packages/components/tests/acp-selector-options.test.ts b/packages/components/tests/acp-selector-options.test.ts index cabe64637..76dcce369 100644 --- a/packages/components/tests/acp-selector-options.test.ts +++ b/packages/components/tests/acp-selector-options.test.ts @@ -8,10 +8,14 @@ import { import { buildAcpSelectorOptions, buildAllConfigOptionSelectors, + isFastModeSelector, normalizeCodexReasoningEffortSelectors, + resolveFastModeSelectorEnabled, resolvePlanModeSelectorEnabled, + toggleFastModeSelectorValue, togglePlanModeSelectorValue, type AcpConfigOptionSelector, + type AcpSelectorTarget, } from '../src/components/shared/acp-selector-options'; const agentConfigId = 'config-1' as AgentConfigId; @@ -19,6 +23,131 @@ const agentConfigId = 'config-1' as AgentConfigId; const machineWithCapabilities = (acpCapabilities: MachineViewMeta['acpCapabilities']) => ({ acpCapabilities }) as Pick; +const cursorThinkingOption = (): AcpConfigOptionSummary => ({ + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'False' }, + { value: 'true', name: 'True' }, + ], +}); + +const cursorEffortOption = (): AcpConfigOptionSummary => ({ + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + ], +}); + +const cursorFastOption = (): AcpConfigOptionSummary => ({ + id: 'fast', + name: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'False' }, + { value: 'true', name: 'True' }, + ], +}); + +const cursorContextOption = (): AcpConfigOptionSummary => ({ + id: 'context', + name: 'Context', + category: 'model_config', + type: 'select', + currentValue: '200k', + options: [ + { value: '200k', name: '200k' }, + { value: '1m', name: '1m' }, + ], +}); + +const cursorReasoningOption = (): 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' }, + ], +}); + +const cursorSnapshotOptions = (): AcpConfigOptionSummary[] => [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'a', + options: [ + { value: 'a', name: 'A' }, + { value: 'b', name: 'B' }, + { value: 'c', name: 'C' }, + ], + }, + { + id: 'mode', + name: 'Mode', + category: 'mode', + type: 'select', + currentValue: 'agent', + options: [{ value: 'agent', name: 'Agent' }], + }, + cursorThinkingOption(), + cursorEffortOption(), + cursorFastOption(), + cursorContextOption(), +]; + +const cursorCatalogByModel = (): Record => ({ + a: [cursorThinkingOption(), cursorEffortOption(), cursorFastOption(), cursorContextOption()], + b: [cursorReasoningOption(), cursorFastOption()], + c: [], +}); + +const cursorCapabilityEntry = ( + cliType: 'registry' | 'builtin', + agentType: string +): NonNullable[string] => ({ + cliType, + agentType, + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime', + modes: [], + models: [], + configOptions: cursorSnapshotOptions(), + configOptionsByModel: cursorCatalogByModel(), + fetchedAt: 1, +}); + +const buildCursorRegistryOptions = ( + target: Pick = {} +) => + buildAcpSelectorOptions({ + configId: agentConfigId, + cliType: 'registry', + agentType: 'cursor', + machine: machineWithCapabilities({ + [agentConfigId]: cursorCapabilityEntry('registry', 'cursor'), + }), + ...target, + }); + +const selectorIds = (options: ReturnType) => + options.configOptionSelectors.map((selector) => selector.configId); + const codexMachineWithConfigOptions = (configOptions: AcpConfigOptionSummary[]) => machineWithCapabilities({ [agentConfigId]: { @@ -891,3 +1020,103 @@ describe('plan mode selector value semantics', () => { expect(togglePlanModeSelectorValue(planCurrent, 'bogus')).toBe('default'); }); }); + +describe('per-model catalog composition', () => { + it('composes model b options from the catalog and keeps the snapshot model list', () => { + const options = buildCursorRegistryOptions({ configOptionValues: { model: 'b' } }); + expect(selectorIds(options)).toEqual(['model', 'mode', 'reasoning', 'fast']); + const modelSelector = options.configOptionSelectors.find( + (selector) => selector.configId === 'model' + ); + expect(modelSelector?.options.map((option) => option.value)).toEqual(['a', 'b', 'c']); + }); + + it('prefers the registry model config option over a stale selectedModelId', () => { + const options = buildCursorRegistryOptions({ + selectedModelId: 'a', + configOptionValues: { model: 'b' }, + }); + expect(selectorIds(options)).toEqual(['model', 'mode', 'reasoning', 'fast']); + }); + + it('drops per-model options for a known model with an empty catalog entry', () => { + const options = buildCursorRegistryOptions({ configOptionValues: { model: 'c' } }); + expect(selectorIds(options)).toEqual(['model', 'mode']); + }); + + it('returns the probe snapshot when the target has no model', () => { + const options = buildCursorRegistryOptions(); + expect(selectorIds(options)).toEqual([ + 'model', + 'mode', + 'thinking', + 'effort', + 'fast', + 'context', + ]); + }); + + it('uses selectedModelId for a builtin target and ignores configOptionValues.model', () => { + const options = buildAcpSelectorOptions({ + configId: agentConfigId, + cliType: 'builtin', + agentType: 'claude', + selectedModelId: 'b', + configOptionValues: { model: 'a' }, + machine: machineWithCapabilities({ + [agentConfigId]: cursorCapabilityEntry('builtin', 'claude'), + }), + }); + expect(selectorIds(options)).toEqual(['reasoning', 'fast']); + }); +}); + +describe('fast mode selector value semantics', () => { + const trueFalseFastSelector: AcpConfigOptionSelector = { + configId: 'fast', + label: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', label: 'False' }, + { value: 'true', label: 'True' }, + ], + }; + const onOffFastSelector: AcpConfigOptionSelector = { + configId: 'fast', + label: 'Fast', + type: 'select', + currentValue: 'off', + options: [ + { value: 'off', label: 'Off' }, + { value: 'on', label: 'On' }, + ], + }; + const booleanFastSelector: AcpConfigOptionSelector = { + configId: 'fast', + label: 'Fast', + type: 'boolean', + currentValue: true, + options: [], + }; + + it('classifies a true/false fast select from the Cursor catalog as a fast-mode toggle', () => { + const options = buildCursorRegistryOptions(); + const selector = options.configOptionSelectors.find( + (candidate) => candidate.configId === 'fast' + ); + expect(selector && isFastModeSelector(selector)).toBe(true); + expect(resolveFastModeSelectorEnabled(trueFalseFastSelector, 'true')).toBe(true); + expect(toggleFastModeSelectorValue(trueFalseFastSelector, 'true')).toBe('false'); + }); + + it('toggles on/off and boolean fast selectors to their own value families', () => { + expect(isFastModeSelector(onOffFastSelector)).toBe(true); + expect(toggleFastModeSelectorValue(onOffFastSelector, 'on')).toBe('off'); + expect(toggleFastModeSelectorValue(onOffFastSelector, 'off')).toBe('on'); + expect(isFastModeSelector(booleanFastSelector)).toBe(true); + expect(toggleFastModeSelectorValue(booleanFastSelector, true)).toBe(false); + expect(toggleFastModeSelectorValue(booleanFastSelector, false)).toBe(true); + }); +}); diff --git a/packages/components/tests/agent-role-detail-pane.test.tsx b/packages/components/tests/agent-role-detail-pane.test.tsx index 32b789412..4ebde9add 100644 --- a/packages/components/tests/agent-role-detail-pane.test.tsx +++ b/packages/components/tests/agent-role-detail-pane.test.tsx @@ -4,12 +4,15 @@ import { act, createElement, type ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { + ACP_CAPABILITY_CACHE_VERSION, AGENT_ROLE_VERSION, + type AcpConfigOptionSummary, type AgentConfigId, type AgentConfigMeta, type AgentRole, type AgentRoleId, type MachineId, + type MachineViewMeta, } from '@lody/shared'; import { AgentRoleDetailPane } from '../src/components/sessions/agent-role-detail-pane'; @@ -110,6 +113,54 @@ describe('AgentRoleDetailPane', () => { expect(withoutMachine.textContent).not.toContain('Studio'); }); + it('resolves pinned options against the catalog of the model the Role stores', async () => { + const select = ( + id: string, + category: string, + values: string[], + currentValue = values[0] ?? '' + ): AcpConfigOptionSummary => ({ + id, + name: id.charAt(0).toUpperCase() + id.slice(1), + category, + type: 'select', + currentValue, + options: values.map((value) => ({ + value, + name: value.charAt(0).toUpperCase() + value.slice(1), + })), + }); + // The probe snapshot describes model `a`; the Role pins model `b`, whose + // catalog entry publishes `reasoning`, an option the snapshot never had. + const machine: Pick = { + acpCapabilities: { + 'config-1': { + cliType: 'registry', + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime', + modes: [], + models: [], + configOptions: [ + select('model', 'model', ['a', 'b'], 'a'), + select('effort', 'thought_level', ['low', 'high']), + ], + configOptionsByModel: { + a: [select('effort', 'thought_level', ['low', 'high'])], + b: [select('reasoning', 'thought_level', ['low', 'medium', 'high'])], + }, + fetchedAt: 1, + }, + }, + }; + const view = await render({ + agentConfig: { ...agentConfig, cliType: 'registry', agentType: 'cursor', name: 'Cursor' }, + machine, + role: role({ runConfig: { configOptionValues: { model: 'b', reasoning: 'high' } } }), + }); + expect(rowValue(view, 'Reasoning')).toBe('High'); + }); + it('shows the instruction itself and offers editing only where it can be done', async () => { const view = await render({ role: role({ promptPrefix: 'Correctness before style.' }) }); expect(view.textContent).toContain('Correctness before style.'); From 8cccd7a4f8c0deca557d432bdcf2554ba781b9e6 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 17:08:14 +0800 Subject: [PATCH 7/9] feat(components): render toggle-shaped thinking options as their own row Cursor publishes thinking as a two-valued select next to a multi-level effort ladder, and Kimi models without effort levels publish thinking as off/on. Both now render as a Thinking toggle row between Plan and Fast in the desktop run-config menu and the mobile sheet, reusing the existing ToggleItem and ToggleRow and the agent's own option label, and both faces carry a Brain mark while the toggle is on, like the Fast mark. The Role inert face follows the same face parts. Stories gain a registry Cursor catalog case per surface. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- .../src/components/mobile/AGENTS.md | 4 +- .../mobile/mobile-run-config-button.tsx | 20 +- .../mobile/mobile-run-config-sheet.tsx | 22 +- .../src/components/sessions/AGENTS.md | 14 +- .../sessions/desktop-run-config-menu.tsx | 43 +++- .../stories/DesktopRunConfigMenu.stories.tsx | 158 ++++++++++++++ .../stories/MobileRunConfigButton.stories.tsx | 137 ++++++++++++ .../stories/MobileRunConfigSheet.stories.tsx | 142 ++++++++++++- ...esktop-run-config-thinking-toggle.test.tsx | 162 ++++++++++++++ .../tests/run-config-thinking-face.test.tsx | 200 ++++++++++++++++++ 10 files changed, 889 insertions(+), 13 deletions(-) create mode 100644 packages/components/tests/desktop-run-config-thinking-toggle.test.tsx create mode 100644 packages/components/tests/run-config-thinking-face.test.tsx diff --git a/packages/components/src/components/mobile/AGENTS.md b/packages/components/src/components/mobile/AGENTS.md index d7ee5d1f9..8f19be3e0 100644 --- a/packages/components/src/components/mobile/AGENTS.md +++ b/packages/components/src/components/mobile/AGENTS.md @@ -230,10 +230,10 @@ embedded` lazy-imported from `../tasks/tasks-workspace.tsx` (`embedded` `mobile-session-run-config.tsx`. It takes `agentSelection` (no SessionMeta dependency) plus model/mode/config props, renders the collapsed `mobile-run-config-button.tsx` face - (`[agent icon] model · reasoning · [mode face] · plan/fast`; mode face = + (`[agent icon] model · reasoning · [mode face] · plan/thinking/fast`; mode face = `permission-mode-face.tsx`, classified by `@lody/shared` `classifyPermissionModeFace`), and opens `mobile-run-config-sheet.tsx` - (Role/Agent/Model/Interaction/Reasoning/Permission/Plan/Fast rows plus + (Role/Agent/Model/Interaction/Reasoning/Permission/Plan/Thinking/Fast rows plus provider-defined select rows; Agent/Model/Interaction/Reasoning/Permission and provider-defined selects use coordinated inline pickers; explicit permission selectors take precedence over legacy ACP modes; closing the sheet must not restore diff --git a/packages/components/src/components/mobile/mobile-run-config-button.tsx b/packages/components/src/components/mobile/mobile-run-config-button.tsx index 8051d5fb1..331ff25f2 100644 --- a/packages/components/src/components/mobile/mobile-run-config-button.tsx +++ b/packages/components/src/components/mobile/mobile-run-config-button.tsx @@ -1,5 +1,5 @@ import { useMemo, type ReactNode } from 'react'; -import { ListChecks, Zap } from 'lucide-react'; +import { Brain, ListChecks, Zap } from 'lucide-react'; import { classifyPermissionModeFace } from '@lody/shared'; import { @@ -67,6 +67,7 @@ export function useRunConfigFace({ permissionModeSelectors, modeSelectors, thoughtLevelSelectors, + thoughtToggleSelectors, planModeSelectors, fastModeSelectors, } = useMemo(() => orderAcpConfigOptionSelectors(configOptionSelectors), [configOptionSelectors]); @@ -125,8 +126,11 @@ export function useRunConfigFace({ const fastOn = fastSelector ? resolveOnOffConfigOptionEnabled(fastSelector, configOptionValues?.[fastSelector.configId]) : false; + const thoughtToggleOn = thoughtToggleSelectors.some((selector) => + resolveOnOffConfigOptionEnabled(selector, configOptionValues?.[selector.configId]) + ); - return { modelLabel, thinkingLabel, modeId, planOn, fastOn }; + return { modelLabel, thinkingLabel, modeId, planOn, fastOn, thoughtToggleOn }; } /** Middle-dot separator between the face's identity/status groups. */ @@ -145,8 +149,9 @@ export function MobileRunConfigButton({ ariaLabel = 'Run configuration', ...faceProps }: MobileRunConfigButtonProps) { - const { modelLabel, thinkingLabel, modeId, planOn, fastOn } = useRunConfigFace(faceProps); - const hasToggle = planOn || fastOn; + const { modelLabel, thinkingLabel, modeId, planOn, fastOn, thoughtToggleOn } = + useRunConfigFace(faceProps); + const hasToggle = planOn || fastOn || thoughtToggleOn; // The indicator hides itself for default/unknown modes; mirror that here so // the separator dot never renders next to nothing. const modeVisible = classifyPermissionModeFace(modeId).kind !== 'hidden'; @@ -204,6 +209,13 @@ export function MobileRunConfigButton({ aria-hidden="true" /> ) : null} + {thoughtToggleOn ? ( +