From 14eb6db684043918806648d68fbece49d4ccc0ef Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 16:42:58 +0800 Subject: [PATCH 01/13] 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 243e75fff..0d4ee9cf7 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -309,6 +309,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 042de03b280cbedc7212a2611ef558a7ccf72bef Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 16:43:13 +0800 Subject: [PATCH 02/13] 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/acp-capabilities.test.ts | 51 ++++ apps/cli/src/agent/acp-capabilities.ts | 18 +- .../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 +- 9 files changed, 769 insertions(+), 15 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/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 6880828c1..d26a3ca3a 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,13 +91,18 @@ export async function fetchAcpCapabilities( }); try { + const normalized = normalizeAcpSessionCapabilities(sessionResponse, { + sessionFork: client.supportsSessionFork?.() === true, + acknowledgedSteer: client.supportsAcknowledgedSteer(), + agent: { cliType, agentType }, + }); + const configOptionsByModel = isRegistryCursorAgent({ cliType, agentType }) + ? await fetchCursorModelCatalog({ client, signal: options.signal, logger }) + : undefined; return { - ...normalizeAcpSessionCapabilities(sessionResponse, { - sessionFork: client.supportsSessionFork?.() === true, - acknowledgedSteer: client.supportsAcknowledgedSteer(), - agent: { cliType, agentType }, - }), + ...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 9c1f840c6..c96ca06e1 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -510,6 +510,7 @@ export type SessionExecutionServiceDeps = { modes: NonNullable; models: NonNullable; configOptions?: AcpConfigOptionSummary[]; + configOptionsByModel?: Record; availableCommands?: AcpCommandSummary[]; sessionFork: boolean; acknowledgedSteer: boolean; @@ -5223,6 +5224,7 @@ export class SessionExecutionService { modes, models, configOptions, + configOptionsByModel, availableCommands, sessionFork, acknowledgedSteer, @@ -5266,7 +5268,10 @@ export class SessionExecutionService { }), modelReasoningEfforts, acknowledgedSteer, - { signal: options.signal } + { + signal: options.signal, + ...(configOptionsByModel !== undefined ? { configOptionsByModel } : {}), + } ); return { From 5494231d564e0f217dc5e5ed622eed93d2c6b537 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 16:43:26 +0800 Subject: [PATCH 03/13] 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 3c265988f287c44e40ca973a9cc1f6f20f766957 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 08:07:52 +0800 Subject: [PATCH 04/13] 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 05d228b70..649b97642 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 8210fab33b8623e4f677141e6da7d587c77e0102 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 08:07:52 +0800 Subject: [PATCH 05/13] 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/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 +++++++++++++++++++ 8 files changed, 160 insertions(+), 29 deletions(-) create mode 100644 packages/shared/tests/acp-capability-cache.test.ts diff --git a/apps/cli/src/agent/acp-capabilities.ts b/apps/cli/src/agent/acp-capabilities.ts index d26a3ca3a..b60f51886 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 0d4ee9cf7..50fec1f3d 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -330,9 +330,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 ecfd2d7cd18d39e52fded6ec199658a64c6a2ba6 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 11:20:23 +0800 Subject: [PATCH 06/13] fix: validate saved role models against current capabilities Keep unsupported model and mode roles visible with a precise reason while excluding them from mentions and composer selection. Preserve the saved role while its capability snapshot is still unknown. Model: gpt-6-astra --- locales/en.json | 2 + locales/zh_CN.json | 2 + packages/components/src/AGENTS.md | 6 + .../src/components/chat/chat-landing.tsx | 39 +-- .../src/hooks/use-chat-landing-defaults.ts | 41 ++++ .../src/hooks/use-workspace-agent-roles.ts | 16 +- .../src/lib/composer-agent-roles.ts | 2 + .../src/stories/AgentRoleRow.stories.tsx | 11 + .../components/tests/agent-role-row.test.tsx | 13 + .../use-agent-role-availability.test.tsx | 226 ++++++++++++++++++ .../tests/use-chat-landing-defaults.test.tsx | 110 ++++++++- packages/shared/src/agent-role.ts | 55 ++++- packages/shared/tests/agent-role.test.ts | 152 ++++++++++++ 13 files changed, 641 insertions(+), 34 deletions(-) create mode 100644 packages/components/tests/use-agent-role-availability.test.tsx diff --git a/locales/en.json b/locales/en.json index 91870f24f..a97863e76 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3814,6 +3814,8 @@ "settings.agentRoles.unavailable.machineUnknown": "its machine is not available to you", "settings.agentRoles.unavailable.agentConfigMissing": "its agent config no longer exists", "settings.agentRoles.unavailable.agentConfigMismatch": "its agent config belongs to another machine", + "settings.agentRoles.unavailable.modelUnsupported": "its saved model is no longer supported; edit the role to choose another model", + "settings.agentRoles.unavailable.modeUnsupported": "its saved mode is no longer supported; edit the role to choose another mode", "settings.agentRoles.form.name": "Name", "settings.agentRoles.form.sectionTarget": "Where it runs", "settings.agentRoles.form.sectionTargetHint": "A role binds one machine and one agent config. If either becomes unavailable the role stops being mentionable instead of running somewhere else.", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index c5e5e9cca..8760613cc 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -3814,6 +3814,8 @@ "settings.agentRoles.unavailable.machineUnknown": "你无法访问它所在的机器", "settings.agentRoles.unavailable.agentConfigMissing": "它绑定的 Agent 配置已不存在", "settings.agentRoles.unavailable.agentConfigMismatch": "它绑定的 Agent 配置属于另一台机器", + "settings.agentRoles.unavailable.modelUnsupported": "它保存的模型已不受支持,请编辑角色重新选择模型", + "settings.agentRoles.unavailable.modeUnsupported": "它保存的模式已不受支持,请编辑角色重新选择模式", "settings.agentRoles.form.name": "名称", "settings.agentRoles.form.sectionTarget": "运行位置", "settings.agentRoles.form.sectionTargetHint": "角色绑定一台机器和一个 Agent 配置。任一不可用时,角色会停止出现在提及菜单中,而不会改到别处运行。", diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index 2723a9d66..20209f650 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -57,6 +57,12 @@ Parent `AGENTS.md` files also apply. Luna exposes Max only. Keep this aligned with the ACP model catalog; a model version threshold cannot represent per-model differences, and cached efforts may belong to a different selected model. +- Role availability uses `resolveAgentRoleAvailability` with the bound agent's current + capability cache, including its runtime override identity. A stored model or mode + that is no longer advertised keeps the Role listed with its reason and removes it + from mention and composer selection. Missing or stale capability data stays unknown; + never migrate a saved selection or substitute the agent's default to make it available. + Restoring the last-used Role retains its saved id while availability is unknown. ## ACP authentication diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 65111d598..5a6b4ceb2 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -99,7 +99,10 @@ import { SessionCreateBillingError, useSessionActions, } from '@/hooks/use-session-actions'; -import { useChatLandingDefaults } from '@/hooks/use-chat-landing-defaults'; +import { + useChatLandingDefaults, + useRestoreChatLandingAgentRole, +} from '@/hooks/use-chat-landing-defaults'; import { useAcpSessionConfigSelectionState, useResolvedAcpSessionConfigSelection, @@ -3598,33 +3601,15 @@ function WorkspaceChatLanding({ workspaceAgentRoles, ]); - /* Restore the last-used Role once, and only once the catalog can answer. - Until the workspace document has synced, "not in the list" means "not - loaded yet", so giving up then would silently drop the stored Role. */ - useEffect(() => { - if (agentRoleRestored || !defaultsReady) return; - const storedRoleId = readChatLandingDefaults(workspaceId)?.agentRoleId as - | AgentRoleId - | undefined; - if (!storedRoleId) { - setAgentRoleRestored(true); - return; - } - const item = composerAgentRoleItems.find((entry) => entry.role.id === storedRoleId); - if (!item) { - if (agentRolesSynced) setAgentRoleRestored(true); - return; - } - setAgentRoleRestored(true); - handleAgentRoleSelect(storedRoleId); - }, [ - agentRoleRestored, - agentRolesSynced, - composerAgentRoleItems, - defaultsReady, - handleAgentRoleSelect, + useRestoreChatLandingAgentRole({ workspaceId, - ]); + defaultsReady, + restored: agentRoleRestored, + setRestored: setAgentRoleRestored, + items: composerAgentRoleItems, + catalogSynced: agentRolesSynced, + onSelect: handleAgentRoleSelect, + }); const agentRolePinsPermissionMode = useMemo(() => { if (!activeAgentRole) return false; const { source } = resolvePermissionModeFace({ diff --git a/packages/components/src/hooks/use-chat-landing-defaults.ts b/packages/components/src/hooks/use-chat-landing-defaults.ts index 62d79f136..f730d586d 100644 --- a/packages/components/src/hooks/use-chat-landing-defaults.ts +++ b/packages/components/src/hooks/use-chat-landing-defaults.ts @@ -7,6 +7,7 @@ import type { MachineViewMeta, } from '@lody/shared'; import type { AgentSelection } from '@/components/shared'; +import type { ComposerAgentRoleItem } from '@/lib/composer-agent-roles'; import { readChatLandingDefaults, resolvePreferredChatLandingAgentSelection, @@ -14,6 +15,46 @@ import { } from '@/lib/chat-landing-defaults'; type LocalProjectSelection = { machineId: MachineId; localProjectId: LocalProjectId }; +/** Restore only after both the catalog and the bound agent's capabilities can answer. */ +export function useRestoreChatLandingAgentRole({ + workspaceId, + defaultsReady, + restored, + setRestored, + items, + catalogSynced, + onSelect, +}: { + workspaceId: string | null; + defaultsReady: boolean; + restored: boolean; + setRestored: (restored: boolean) => void; + items: readonly ComposerAgentRoleItem[]; + catalogSynced: boolean; + onSelect: (roleId: AgentRoleId) => void; +}): void { + useEffect(() => { + if (restored || !defaultsReady) return; + const storedRoleId = readChatLandingDefaults(workspaceId)?.agentRoleId as + | AgentRoleId + | undefined; + if (!storedRoleId) { + setRestored(true); + return; + } + const item = items.find((entry) => entry.role.id === storedRoleId); + if (!item) { + if (catalogSynced) setRestored(true); + return; + } + // Completing now would persist None before a current capability snapshot + // can restore a still-supported Role, permanently losing its saved choice. + if (item.availability.kind === 'unknown') return; + setRestored(true); + if (item.availability.kind === 'available') onSelect(storedRoleId); + }, [catalogSynced, defaultsReady, items, onSelect, restored, setRestored, workspaceId]); +} + type UseChatLandingDefaultsArgs = { workspaceId: string | null; shouldRestoreContextType: boolean; diff --git a/packages/components/src/hooks/use-workspace-agent-roles.ts b/packages/components/src/hooks/use-workspace-agent-roles.ts index 142deb242..4e1100ea6 100644 --- a/packages/components/src/hooks/use-workspace-agent-roles.ts +++ b/packages/components/src/hooks/use-workspace-agent-roles.ts @@ -2,8 +2,11 @@ import { useCallback, useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import { + getAcpCapabilityCacheKey, + isAcpCapabilityCacheEntryCurrentForRuntimeOverrides, listAccessibleAgentRoles, resolveAgentRoleAvailability, + type AcpCapabilityCacheEntry, type AgentConfigId, type AgentRole, type AgentRoleAvailability, @@ -100,14 +103,25 @@ export function useAgentRoleAvailability( const context = useMemo(() => { const agentConfigMachineIds = new Map(); + const agentConfigCapabilities = new Map(); for (const config of agentConfigs) { - if (config.machineId) agentConfigMachineIds.set(config.id, config.machineId); + if (!config.machineId) continue; + agentConfigMachineIds.set(config.id, config.machineId); + const capability = machines.get(config.machineId)?.acpCapabilities?.[ + getAcpCapabilityCacheKey(config.id) + ]; + if ( + isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(capability, config.runtimeOverrides) + ) { + agentConfigCapabilities.set(config.id, capability); + } } return { authorizedMachineIds: new Set(machines.keys()), onlineMachineIds, agentConfigMachineIds, loadedAgentConfigMachineIds: new Set(loadedMachineIds), + agentConfigCapabilities, }; }, [agentConfigs, loadedMachineIds, machines, onlineMachineIds]); diff --git a/packages/components/src/lib/composer-agent-roles.ts b/packages/components/src/lib/composer-agent-roles.ts index 75468ad8e..2de28bdf1 100644 --- a/packages/components/src/lib/composer-agent-roles.ts +++ b/packages/components/src/lib/composer-agent-roles.ts @@ -209,6 +209,8 @@ export const AGENT_ROLE_UNAVAILABLE_REASON_KEYS = { machine_offline: 'settings.agentRoles.unavailable.machineOffline', agent_config_missing: 'settings.agentRoles.unavailable.agentConfigMissing', agent_config_machine_mismatch: 'settings.agentRoles.unavailable.agentConfigMismatch', + model_unsupported: 'settings.agentRoles.unavailable.modelUnsupported', + mode_unsupported: 'settings.agentRoles.unavailable.modeUnsupported', } as const satisfies Record; export type ComposerRunConfigValues = { diff --git a/packages/components/src/stories/AgentRoleRow.stories.tsx b/packages/components/src/stories/AgentRoleRow.stories.tsx index 15cb3909e..dbdd0674a 100644 --- a/packages/components/src/stories/AgentRoleRow.stories.tsx +++ b/packages/components/src/stories/AgentRoleRow.stories.tsx @@ -85,6 +85,17 @@ export const AgentConfigMissing: Story = { }, }; +export const SavedCursorModelUnavailable: Story = { + args: { + role: { + ...base, + runConfig: { configOptionValues: { model: 'sonnet-4.5-thinking' } }, + }, + agentConfig: { cliType: 'registry', agentType: 'cursor', env: {}, name: 'Cursor' }, + availability: { kind: 'unavailable', reason: 'model_unsupported' }, + }, +}; + /** That machine's configs have not been read yet, so nothing is claimed. */ export const CheckingAvailability: Story = { args: { availability: { kind: 'unknown' } }, diff --git a/packages/components/tests/agent-role-row.test.tsx b/packages/components/tests/agent-role-row.test.tsx index 00502acc5..8e27b32c7 100644 --- a/packages/components/tests/agent-role-row.test.tsx +++ b/packages/components/tests/agent-role-row.test.tsx @@ -122,6 +122,19 @@ describe('AgentRoleRow', () => { expect(view.textContent).not.toContain('Unavailable'); }); + it.each(['model', 'mode'] as const)( + 'keeps a role with an unsupported %s visible and editable', + async (selection) => { + const view = await render({ + availability: { kind: 'unavailable', reason: `${selection}_unsupported` }, + }); + expect(view.textContent).toContain('Code Reviewer'); + expect(view.textContent).toContain(`its saved ${selection} is no longer supported`); + expect(view.textContent).toContain(`edit the role to choose another ${selection}`); + expect(view.querySelector('button[aria-label="Edit"]')).not.toBeNull(); + } + ); + it('names the agent config when a role pins no run config of its own', async () => { const view = await render({ role: { ...role, runConfig: {} } }); expect(view.textContent).toContain('Codex'); diff --git a/packages/components/tests/use-agent-role-availability.test.tsx b/packages/components/tests/use-agent-role-availability.test.tsx new file mode 100644 index 000000000..bf7fabbc0 --- /dev/null +++ b/packages/components/tests/use-agent-role-availability.test.tsx @@ -0,0 +1,226 @@ +// @vitest-environment jsdom + +import { act, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider, createStore, type Store } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ACP_CAPABILITY_CACHE_VERSION, + CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX, + getAcpCapabilityCacheKey, + getLodyMachinePresenceKey, + machineFlockKeys, + selectMentionableAgentRoles, + serializeMachineFlockKey, + type AcpCapabilityCacheEntry, + type AgentConfigId, + type AgentConfigMeta, + type AgentRole, + type AgentRoleAvailability, + type AgentRoleId, + type LodyPresenceInstanceId, + type MachineId, + type MachineViewMeta, + type WorkspaceId, +} from '@lody/shared'; + +const visibleMachines = vi.hoisted(() => ({ machines: new Map() })); + +vi.mock('../src/hooks/use-visible-machine-metas', () => ({ + useVisibleMachineMetas: () => visibleMachines, +})); +// Flock subscription is an I/O boundary; publish its snapshots into the real atoms below. +vi.mock('../src/hooks/use-machine-flock-agent-configs', () => ({ + useMachineFlockAgentConfigsForMachineIds: () => {}, +})); + +import { setMachineFlockRowsForMachineAtom } from '../src/atoms/machine-flock'; +import { lodyPresenceNowMsAtom, lodyPresenceStatesAtom } from '../src/atoms/presence'; +import { runtimeAtom, type WorkspaceRuntime } from '../src/atoms/runtime'; +import { useAgentRoleAvailability } from '../src/hooks/use-workspace-agent-roles'; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const machineId = 'cursor-machine' as MachineId; +const workspaceId = 'role-workspace' as WorkspaceId; +const configId = 'cursor-config' as AgentConfigId; +const currentModelId = 'model-b'; +const legacyModelId = 'model-b-legacy-variant'; +const now = 1_000; +const agentConfig: AgentConfigMeta = { + id: configId, + machineId, + name: 'Cursor', + cliType: 'registry', + agentType: 'cursor', + env: {}, + prompt: '', +}; + +const capability = (): AcpCapabilityCacheEntry => ({ + cliType: 'registry', + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + sourceVersion: `synthetic-cursor${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}`, + provenance: 'runtime', + models: [{ modelId: currentModelId, name: 'Model B' }], + modes: [], + configOptions: [ + { + id: 'model', + name: 'Model', + type: 'select', + category: 'model', + currentValue: currentModelId, + options: [{ value: currentModelId, name: 'Model B' }], + }, + ], + fetchedAt: now, +}); + +const role = (runConfig: AgentRole['runConfig']): AgentRole => ({ + v: 1, + id: 'cursor-role' as AgentRoleId, + revision: 1, + name: 'Cursor reviewer', + visibility: 'private', + ownerUserId: 'role-owner', + machineId, + agentConfigId: configId, + runConfig, + createdAt: now, + updatedAt: now, +}); + +type Snapshot = { availability: AgentRoleAvailability; mentionableIds: AgentRoleId[] }; + +describe('useAgentRoleAvailability', () => { + let root: Root; + let container: HTMLDivElement; + let store: Store; + let snapshot: Snapshot | undefined; + + function Harness({ role: selectedRole }: { role: AgentRole }) { + const { resolve } = useAgentRoleAvailability([selectedRole]); + useEffect(() => { + snapshot = { + availability: resolve(selectedRole), + mentionableIds: selectMentionableAgentRoles([selectedRole], { + currentUserId: selectedRole.ownerUserId, + scope: { kind: 'machine', machineId }, + getAvailability: resolve, + }).map((item) => item.id), + }; + }, [resolve, selectedRole]); + return null; + } + + function publishCapability(entry?: AcpCapabilityCacheEntry) { + visibleMachines.machines = new Map([ + [ + machineId, + { + id: machineId, + name: 'Cursor machine', + cliVersion: '0.0.0', + os: 'linux', + sessions: [], + raceLimits: {}, + acpCapabilities: entry ? { [getAcpCapabilityCacheKey(configId)]: entry } : {}, + }, + ], + ]); + } + + async function publishAgentConfig() { + const key = machineFlockKeys.agentConfig(configId); + await act(async () => { + store.set(setMachineFlockRowsForMachineAtom, { + workspaceId, + machineId, + rows: { [serializeMachineFlockKey(key)]: { key, value: agentConfig } }, + }); + }); + } + + async function render(selectedRole: AgentRole) { + await act(async () => { + root.render( + + + + ); + }); + } + + beforeEach(() => { + snapshot = undefined; + store = createStore(); + store.set(runtimeAtom, { workspaceId, workspaceSlug: 'role-workspace' } as WorkspaceRuntime); + store.set(lodyPresenceNowMsAtom, now); + const instanceId = 'cursor-instance' as LodyPresenceInstanceId; + store.set(lodyPresenceStatesAtom, { + [getLodyMachinePresenceKey(machineId, instanceId)]: { + kind: 'machine', + machineId, + instanceId, + updatedAt: now, + }, + }); + publishCapability(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it.each(['modelId', 'configOptionValues'] as const)( + 'withholds a legacy Cursor Role stored through %s until its owner selects an advertised model', + async (channel) => { + const selection = (modelId: string): AgentRole['runConfig'] => + channel === 'modelId' ? { modelId } : { configOptionValues: { model: modelId } }; + const savedRole = role(selection(legacyModelId)); + + await render(savedRole); + expect(snapshot).toEqual({ availability: { kind: 'unknown' }, mentionableIds: [] }); + + await publishAgentConfig(); + expect(snapshot).toEqual({ availability: { kind: 'unknown' }, mentionableIds: [] }); + + publishCapability(capability()); + await render(savedRole); + expect(snapshot).toEqual({ + availability: { kind: 'unavailable', reason: 'model_unsupported' }, + mentionableIds: [], + }); + + await render({ ...savedRole, revision: 2, runConfig: selection(currentModelId) }); + expect(snapshot).toEqual({ + availability: { kind: 'available' }, + mentionableIds: [savedRole.id], + }); + expect(savedRole.runConfig).toEqual(selection(legacyModelId)); + } + ); + + it('keeps an expired capability cache unknown until a current snapshot arrives', async () => { + const savedRole = role({ modelId: currentModelId }); + await publishAgentConfig(); + publishCapability({ ...capability(), cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1 }); + await render(savedRole); + expect(snapshot).toEqual({ availability: { kind: 'unknown' }, mentionableIds: [] }); + + publishCapability(capability()); + await render(savedRole); + expect(snapshot).toEqual({ + availability: { kind: 'available' }, + mentionableIds: [savedRole.id], + }); + }); +}); diff --git a/packages/components/tests/use-chat-landing-defaults.test.tsx b/packages/components/tests/use-chat-landing-defaults.test.tsx index 357361803..f0908f1ec 100644 --- a/packages/components/tests/use-chat-landing-defaults.test.tsx +++ b/packages/components/tests/use-chat-landing-defaults.test.tsx @@ -3,14 +3,21 @@ import { act, useEffect, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { AgentConfigId, MachineId } from '@lody/shared'; +import type { AgentConfigId, AgentRole, AgentRoleId, MachineId } from '@lody/shared'; import type { AcpSelectorOptions } from '../src/components/shared/acp-selector-options'; import { useAcpSessionConfigSelectionState, useResolvedAcpSessionConfigSelection, } from '../src/hooks/use-acp-session-config-selection'; -import { useChatLandingDefaults } from '../src/hooks/use-chat-landing-defaults'; -import { writeChatLandingDefaults } from '../src/lib/chat-landing-defaults'; +import { + useChatLandingDefaults, + useRestoreChatLandingAgentRole, +} from '../src/hooks/use-chat-landing-defaults'; +import { + readChatLandingDefaults, + writeChatLandingDefaults, +} from '../src/lib/chat-landing-defaults'; +import type { ComposerAgentRoleItem } from '../src/lib/composer-agent-roles'; import { agentDefaultsCache, persistAgentSessionDefaults } from '../src/lib/local-storage-cache'; ( @@ -109,6 +116,103 @@ describe('chat landing agent session defaults', () => { }); }); +describe('chat landing saved Role restoration', () => { + const workspaceId = 'ws-role-restoration'; + const savedRole: AgentRole = { + v: 1, + id: 'saved-role' as AgentRoleId, + revision: 1, + name: 'Reviewer', + ownerUserId: 'user-1', + visibility: 'private', + machineId, + agentConfigId: agentId, + runConfig: { modelId: 'supported-model' }, + promptPrefix: 'Review the requested change.', + createdAt: 1, + updatedAt: 1, + }; + const noop = () => {}; + let container: HTMLDivElement; + let root: Root; + + function RoleRestoreProbe({ items }: { items: ComposerAgentRoleItem[] }) { + const [restored, setRestored] = useState(false); + const [selectedRoleId, setSelectedRoleId] = useState(null); + const { defaultsReady } = useChatLandingDefaults({ + workspaceId, + shouldRestoreContextType: false, + contextType: 'chat', + setContextType: noop, + executorConfigs: [], + machines: new Map(), + selectableMachines: new Map(), + visibleMachinesLoading: false, + docMetaCacheReady: true, + selectedAgent: null, + setSelectedAgent: noop, + selectedMachineId: null, + setSelectedRepo: noop, + selectedBranch: null, + setSelectedBranch: noop, + selectedLocalProject: null, + setSelectedLocalProject: noop, + selectedLocalBranch: null, + setSelectedLocalBranch: noop, + selectedAgentRoleId: restored ? selectedRoleId : undefined, + }); + useRestoreChatLandingAgentRole({ + workspaceId, + defaultsReady, + restored, + setRestored, + items, + catalogSynced: true, + onSelect: setSelectedRoleId, + }); + return ; + } + + function render(availability: ComposerAgentRoleItem['availability']) { + act(() => { + root.render(); + }); + } + + beforeEach(() => { + localStorage.clear(); + writeChatLandingDefaults(workspaceId, { agentRoleId: savedRole.id }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('retains the saved Role while capabilities are unknown and restores it when they arrive', () => { + render({ kind: 'unknown' }); + expect(container.querySelector('output')?.dataset.restored).toBe('false'); + expect(container.querySelector('output')?.dataset.role).toBe(''); + expect(readChatLandingDefaults(workspaceId)?.agentRoleId).toBe(savedRole.id); + + render({ kind: 'available' }); + expect(container.querySelector('output')?.dataset.restored).toBe('true'); + expect(container.querySelector('output')?.dataset.role).toBe(savedRole.id); + expect(readChatLandingDefaults(workspaceId)?.agentRoleId).toBe(savedRole.id); + }); + + it('finishes restoration without selecting a Role whose saved model is unsupported', () => { + render({ kind: 'unknown' }); + render({ kind: 'unavailable', reason: 'model_unsupported' }); + expect(container.querySelector('output')?.dataset.restored).toBe('true'); + expect(container.querySelector('output')?.dataset.role).toBe(''); + expect(readChatLandingDefaults(workspaceId)?.agentRoleId).toBeNull(); + }); +}); + /* The #185 oscillation regression (session 51e236e0…) lives in tests/session-config-selection-oscillation.test.tsx. */ diff --git a/packages/shared/src/agent-role.ts b/packages/shared/src/agent-role.ts index 9c9667978..b757a122d 100644 --- a/packages/shared/src/agent-role.ts +++ b/packages/shared/src/agent-role.ts @@ -1,4 +1,5 @@ import type { AgentConfigId, AgentRoleId, MachineId } from './ids'; +import type { AcpCapabilityCacheEntry } from './ai'; import { isSensitiveAcpConfigOptionId } from './session-preparation'; /** @@ -319,11 +320,13 @@ export type AgentRoleUnavailableReason = | 'machine_unknown' | 'machine_offline' | 'agent_config_missing' - | 'agent_config_machine_mismatch'; + | 'agent_config_machine_mismatch' + | 'model_unsupported' + | 'mode_unsupported'; export type AgentRoleAvailability = | { kind: 'available' } - /** The binding cannot be judged yet — that machine's configs are not loaded. */ + /** The binding or its pinned model/mode cannot be judged from loaded capabilities yet. */ | { kind: 'unknown' } | { kind: 'unavailable'; reason: AgentRoleUnavailableReason }; @@ -340,6 +343,14 @@ export type AgentRoleAvailabilityContext = { * falling back to another config. */ loadedAgentConfigMachineIds: ReadonlySet; + /** + * Current capability entries for each config, filtered by the caller against + * its runtime overrides. Missing or stale data cannot prove a pin unsupported. + */ + agentConfigCapabilities: ReadonlyMap< + AgentConfigId, + Pick + >; }; export const resolveAgentRoleAvailability = ( @@ -362,7 +373,45 @@ export const resolveAgentRoleAvailability = ( if (!context.onlineMachineIds.has(role.machineId)) { return { kind: 'unavailable', reason: 'machine_offline' }; } - return { kind: 'available' }; + + const { runConfig } = role; + const capability = context.agentConfigCapabilities.get(role.agentConfigId); + if (!capability) { + const hasSelection = + Boolean(runConfig.modelId || runConfig.modeId) || + Object.keys(runConfig.configOptionValues ?? {}).length > 0; + return { kind: hasSelection ? 'unknown' : 'available' }; + } + + let hasUnknownSelection = false; + for (const category of ['model', 'mode'] as const) { + const option = capability.configOptions?.find( + (candidate) => candidate.category === category && candidate.type === 'select' + ); + const selectedValues = [ + runConfig[`${category}Id`], + option ? runConfig.configOptionValues?.[option.id] : undefined, + ].filter((value) => value !== undefined); + if (selectedValues.length === 0) continue; + + // Category selectors supersede the legacy lists. Their values describe + // model/mode identities even when model-dependent option catalogs are absent. + const supportedValues = option + ? option.options.map((value) => value.value) + : category === 'model' + ? capability.models.map((model) => model.modelId) + : capability.modes.map((mode) => mode.id); + if (!option && supportedValues.length === 0) { + hasUnknownSelection = true; + continue; + } + if ( + selectedValues.some((value) => typeof value !== 'string' || !supportedValues.includes(value)) + ) { + return { kind: 'unavailable', reason: `${category}_unsupported` }; + } + } + return { kind: hasUnknownSelection ? 'unknown' : 'available' }; }; // --------------------------------------------------------------------------- diff --git a/packages/shared/tests/agent-role.test.ts b/packages/shared/tests/agent-role.test.ts index 0e4b7664a..2997c1888 100644 --- a/packages/shared/tests/agent-role.test.ts +++ b/packages/shared/tests/agent-role.test.ts @@ -20,6 +20,7 @@ import { type AgentRoleAvailabilityContext, } from '../src/agent-role'; import type { AgentConfigId, AgentRoleId, MachineId } from '../src/ids'; +import type { AcpCapabilityCacheEntry, AcpConfigOptionSummary } from '../src/ai'; const role = (overrides: Partial = {}): AgentRole => ({ v: AGENT_ROLE_VERSION, @@ -43,9 +44,25 @@ const context = ( onlineMachineIds: new Set(['machine-1' as MachineId]), agentConfigMachineIds: new Map([['config-1' as AgentConfigId, 'machine-1' as MachineId]]), loadedAgentConfigMachineIds: new Set(['machine-1' as MachineId]), + agentConfigCapabilities: new Map([ + ['config-1' as AgentConfigId, { models: [{ modelId: 'gpt-5.6', name: 'GPT' }], modes: [] }], + ]), ...overrides, }); +const selector = (category: 'model' | 'mode', values: string[]): AcpConfigOptionSummary => ({ + id: `${category}-selection`, + name: category, + category, + type: 'select', + currentValue: values[0] ?? '', + options: values.map((value) => ({ value, name: value })), +}); + +const capabilityContext = ( + capability: Pick +) => context({ agentConfigCapabilities: new Map([['config-1' as AgentConfigId, capability]]) }); + describe('agent role mention slug', () => { it('keeps non-ASCII text but removes what an `@` token cannot carry', () => { expect(normalizeAgentRoleMentionSlug(' @Code Reviewer ')).toBe('Code-Reviewer'); @@ -198,6 +215,137 @@ describe('agent role availability', () => { resolveAgentRoleAvailability(role(), context({ loadedAgentConfigMachineIds: new Set() })) ).toEqual({ kind: 'unknown' }); }); + + it('keeps a retired model role in the catalog but excludes it from mentions', () => { + const retired = role({ runConfig: { modelId: 'sonnet-4.5-thinking' } }); + const capability = capabilityContext({ + models: [], + modes: [], + configOptions: [selector('model', ['sonnet-4.5'])], + }); + + expect(resolveAgentRoleAvailability(retired, capability)).toEqual({ + kind: 'unavailable', + reason: 'model_unsupported', + }); + expect(listAccessibleAgentRoles([retired], 'user-1')).toEqual([retired]); + expect( + selectMentionableAgentRoles([retired], { + currentUserId: 'user-1', + scope: { kind: 'machine', machineId: 'machine-1' as MachineId }, + getAvailability: (candidate) => resolveAgentRoleAvailability(candidate, capability), + }) + ).toEqual([]); + expect(retired.runConfig).toEqual({ modelId: 'sonnet-4.5-thinking' }); + }); + + it.each(['model', 'mode'] as const)( + 'checks both saved %s channels against its category selector', + (category) => { + const capability = capabilityContext({ + models: [], + modes: [], + configOptions: [selector(category, ['supported'])], + }); + for (const runConfig of [ + { [`${category}Id`]: 'retired' }, + { configOptionValues: { [`${category}-selection`]: 'retired' } }, + { + [`${category}Id`]: 'supported', + configOptionValues: { [`${category}-selection`]: 'retired' }, + }, + ]) { + expect(resolveAgentRoleAvailability(role({ runConfig }), capability)).toEqual({ + kind: 'unavailable', + reason: `${category}_unsupported`, + }); + } + expect( + resolveAgentRoleAvailability( + role({ + runConfig: { + [`${category}Id`]: 'supported', + configOptionValues: { [`${category}-selection`]: 'supported' }, + }, + }), + capability + ) + ).toEqual({ kind: 'available' }); + } + ); + + it('uses current category selectors ahead of the legacy model and mode lists', () => { + const capability = capabilityContext({ + models: [{ modelId: 'retired-model', name: 'Retired model' }], + modes: [{ id: 'retired-mode', name: 'Retired mode' }], + configOptions: [selector('model', ['current-model']), selector('mode', ['current-mode'])], + }); + expect( + resolveAgentRoleAvailability(role({ runConfig: { modelId: 'retired-model' } }), capability) + ).toEqual({ kind: 'unavailable', reason: 'model_unsupported' }); + expect( + resolveAgentRoleAvailability(role({ runConfig: { modeId: 'retired-mode' } }), capability) + ).toEqual({ kind: 'unavailable', reason: 'mode_unsupported' }); + }); + + it('still supports agents publishing only legacy model and mode lists', () => { + const capability = capabilityContext({ + models: [{ modelId: 'legacy-model', name: 'Legacy model' }], + modes: [{ id: 'legacy-mode', name: 'Legacy mode' }], + }); + expect( + resolveAgentRoleAvailability( + role({ runConfig: { modelId: 'legacy-model', modeId: 'legacy-mode' } }), + capability + ) + ).toEqual({ kind: 'available' }); + expect( + resolveAgentRoleAvailability(role({ runConfig: { modeId: 'retired-mode' } }), capability) + ).toEqual({ kind: 'unavailable', reason: 'mode_unsupported' }); + }); + + it('waits for capability data without reporting a broken role or offering it as a mention', () => { + const unread = context({ agentConfigCapabilities: new Map() }); + const pinned = role(); + expect(resolveAgentRoleAvailability(pinned, unread)).toEqual({ kind: 'unknown' }); + expect( + selectMentionableAgentRoles([pinned], { + currentUserId: 'user-1', + scope: { kind: 'machine', machineId: 'machine-1' as MachineId }, + getAvailability: (candidate) => resolveAgentRoleAvailability(candidate, unread), + }) + ).toEqual([]); + expect(resolveAgentRoleAvailability(role({ runConfig: {} }), unread)).toEqual({ + kind: 'available', + }); + }); + + it.each(['model', 'mode'] as const)( + 'keeps a saved %s unknown if the agent publishes no corresponding list', + (category) => { + const saved = role({ runConfig: { [`${category}Id`]: 'selected' } }); + expect( + resolveAgentRoleAvailability(saved, capabilityContext({ models: [], modes: [] })) + ).toEqual({ kind: 'unknown' }); + expect( + resolveAgentRoleAvailability( + saved, + capabilityContext({ models: [], modes: [], configOptions: [selector(category, [])] }) + ) + ).toEqual({ kind: 'unavailable', reason: `${category}_unsupported` }); + } + ); + + it('does not validate model-dependent options against the probe model snapshot', () => { + expect( + resolveAgentRoleAvailability( + role({ + runConfig: { modelId: 'gpt-5.6', configOptionValues: { reasoning_effort: 'high' } }, + }), + context() + ) + ).toEqual({ kind: 'available' }); + }); }); describe('agent role mention scope', () => { @@ -216,6 +364,10 @@ describe('agent role mention scope', () => { ['config-2' as AgentConfigId, 'machine-2' as MachineId], ]), loadedAgentConfigMachineIds: new Set(['machine-1', 'machine-2'] as MachineId[]), + agentConfigCapabilities: new Map([ + ...context().agentConfigCapabilities, + ['config-2' as AgentConfigId, { models: [{ modelId: 'gpt-5.6', name: 'GPT' }], modes: [] }], + ]), }); const getAvailability = (candidate: AgentRole) => resolveAgentRoleAvailability(candidate, bothMachines); From c11eab043950ee3f218ac58c4b2e6a575e66606d Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 16:35:21 +0800 Subject: [PATCH 07/13] feat(cli): observe the Cursor model catalog from created sessions Registry Cursor's per-model option catalog was written only by the explicit capabilities probe, and the first session write after a sourceVersion change dropped it, so every pinned cursor-agent bump needed a manual Settings refresh. Created sessions now call cursor/list_available_models on their live connection inside the existing non-blocking capability update, and MachineDocument.updateAcpCapabilities takes the catalog as a write command: an omitted catalog is inherited for the same config and CLI/agent identity across sourceVersion changes, null (a confirmed -32601) clears it, and a map replaces it. A failed observation keeps the stored catalog and never touches the prompt path. Refs #343 Model: claude-fable-5-1-thinking-max --- apps/cli/src/agent/acp-capabilities.test.ts | 11 + apps/cli/src/agent/acp-capabilities.ts | 15 +- apps/cli/src/lib/loro/AGENTS.md | 11 + apps/cli/src/lib/loro/doc.ts | 34 +- .../machine-document-capabilities.test.ts | 91 ++++- apps/cli/src/lib/message-handler.ts | 19 +- .../src/session/session-execution-service.ts | 35 +- .../tests/session-execution-service.test.ts | 320 +++++++++++++++++- 8 files changed, 502 insertions(+), 34 deletions(-) diff --git a/apps/cli/src/agent/acp-capabilities.test.ts b/apps/cli/src/agent/acp-capabilities.test.ts index 69f8644b1..fd77ee816 100644 --- a/apps/cli/src/agent/acp-capabilities.test.ts +++ b/apps/cli/src/agent/acp-capabilities.test.ts @@ -395,11 +395,22 @@ describe('fetchAcpCapabilities', () => { const customResult = await fetchAcpCapabilities('custom', 'cursor', createSilentLogger()); const builtinResult = await fetchAcpCapabilities('builtin', 'claude', createSilentLogger()); + expect(Object.hasOwn(customResult, 'configOptionsByModel')).toBe(false); + expect(Object.hasOwn(builtinResult, 'configOptionsByModel')).toBe(false); expect(customResult.configOptionsByModel).toBeUndefined(); expect(builtinResult.configOptionsByModel).toBeUndefined(); expect(mocks.fetchCursorModelCatalog).not.toHaveBeenCalled(); }); + it('clears the Cursor model catalog when the agent reports method not found', async () => { + mocks.fetchCursorModelCatalog.mockResolvedValue(undefined); + + const result = await fetchAcpCapabilities('registry', 'cursor', createSilentLogger()); + + expect(Object.hasOwn(result, 'configOptionsByModel')).toBe(true); + expect(result.configOptionsByModel).toBeNull(); + }); + 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' diff --git a/apps/cli/src/agent/acp-capabilities.ts b/apps/cli/src/agent/acp-capabilities.ts index b60f51886..aa5f5f1dc 100644 --- a/apps/cli/src/agent/acp-capabilities.ts +++ b/apps/cli/src/agent/acp-capabilities.ts @@ -1,5 +1,4 @@ import { - type AcpConfigOptionSummary, type AgentConfigCliType, type BuiltinRuntimeOverrides, type CustomAcpLaunchSpec, @@ -16,6 +15,7 @@ import { type AcpCapabilitiesResult, } from '@/agent/acp-capability-normalization'; import { fetchCursorModelCatalog } from '@/agent/cursor-acp'; +import type { AcpCapabilityCatalogWrite } from '@/lib/loro/doc'; export { normalizeConfigOptions } from '@/agent/acp-capability-normalization'; export type { AcpCapabilitiesResult } from '@/agent/acp-capability-normalization'; @@ -27,12 +27,21 @@ export type FetchAcpCapabilitiesOptions = { export type FetchedAcpCapabilities = AcpCapabilitiesResult & { capabilitySourceVersion?: string; - configOptionsByModel?: Record; + /** + * Registry Cursor catalog write for `updateAcpCapabilities`: + * a map replaces the stored catalog, `null` clears it after a confirmed + * JSON-RPC `-32601`, and the field is omitted for non-Cursor agents so the + * stored catalog is inherited. + */ + configOptionsByModel?: AcpCapabilityCatalogWrite; }; /** * Spawns a temporary ACP agent to discover the capabilities returned by session/new. * The agent is killed as soon as the NewSessionResponse has been normalized. + * Registry Cursor also fetches `cursor/list_available_models`: a catalog map + * replaces the stored one, a confirmed `-32601` becomes `null` so the write + * clears a stale catalog, and any other catalog failure rejects the probe. */ export async function fetchAcpCapabilities( cliType: AgentConfigCliType, @@ -98,7 +107,7 @@ export async function fetchAcpCapabilities( agent: { cliType, agentType }, }); const configOptionsByModel = isRegistryCursorAgent({ cliType, agentType }) - ? await fetchCursorModelCatalog({ client, signal: options.signal, logger }) + ? ((await fetchCursorModelCatalog({ client, signal: options.signal, logger })) ?? null) : undefined; return { ...normalized, diff --git a/apps/cli/src/lib/loro/AGENTS.md b/apps/cli/src/lib/loro/AGENTS.md index 4ce47d2b6..a54c0a0fd 100644 --- a/apps/cli/src/lib/loro/AGENTS.md +++ b/apps/cli/src/lib/loro/AGENTS.md @@ -48,6 +48,17 @@ Rules: The dispatch watcher's contract, "session metadata is the activation index", is documented in `../../session/AGENTS.md` and applies to any module enumerating rooms. +## ACP capability rows carry the per-model catalog forward + +`MachineDocument.updateAcpCapabilities` takes the catalog as a write command +(`AcpCapabilityCatalogWrite`), not as a plain field: an omitted +`configOptionsByModel` inherits the stored catalog for the same config and the +same `cliType`/`agentType` across `sourceVersion` changes, `null` clears it +because the agent confirmed it publishes none, and a map (including `{}`) +replaces it. `null` is consumed before the entry is built and never reaches the +Flock row or the wire schema. A session snapshot must not drop a catalog it did +not observe, and a probe that observed "none" must not leave a stale one behind. + ## Shared ACP runtime config contains no secrets `SessionDocument.applyAcpRuntimeConfigPatch` is the durable boundary for the diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index eac69c8db..e4d65ad1c 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; configOptionsByModel?: Record } = {} + options: UpdateAcpCapabilitiesOptions = {} ): Promise { options.signal?.throwIfAborted(); if (!this.machine) { @@ -3109,6 +3109,21 @@ const getAliveDocMeta = async (repo: LoroRepo, roomId: string): Promise & Pick; +/** + * Catalog write command carried by `updateAcpCapabilities`: + * - omitted / `undefined`: the write did not observe the catalog; inherit the stored + * one for the same config and CLI/agent identity, across `sourceVersion` changes; + * - `null`: an observation confirmed the agent publishes no catalog (`-32601`); clear it; + * - a map, including `{}`: replace it. + * `null` is consumed before the entry is built and never reaches the Flock row or the wire. + */ +export type AcpCapabilityCatalogWrite = Record | null; + +export type UpdateAcpCapabilitiesOptions = { + signal?: AbortSignal; + configOptionsByModel?: AcpCapabilityCatalogWrite; +}; + const serializeAcpCapabilityWithoutFetchTime = (entry: AcpCapabilityCacheEntry): string => JSON.stringify({ cliType: entry.cliType, @@ -3209,7 +3224,7 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, - options: { signal?: AbortSignal; configOptionsByModel?: Record } = {} + options: UpdateAcpCapabilitiesOptions = {} ): Promise { options.signal?.throwIfAborted(); const normalizedModes = modes.map((mode) => ({ @@ -3228,13 +3243,16 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { const existing = getMachineFlockAcpCapabilities( readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) )[capabilityKey]; - // omitted keeps the stored catalog for the same sourceVersion + // See AcpCapabilityCatalogWrite: null clears, a map replaces, omitted inherits for + // the same agent identity regardless of sourceVersion. const configOptionsByModel = - options.configOptionsByModel !== undefined - ? options.configOptionsByModel - : existing && existing.sourceVersion === sourceVersion - ? existing.configOptionsByModel - : undefined; + options.configOptionsByModel === null + ? undefined + : options.configOptionsByModel !== undefined + ? options.configOptionsByModel + : existing && existing.cliType === cliType && existing.agentType === agentType + ? existing.configOptionsByModel + : undefined; const entry: AcpCapabilityCacheEntry = { cliType, agentType, 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 28d5fd28c..28c6fb258 100644 --- a/apps/cli/src/lib/loro/machine-document-capabilities.test.ts +++ b/apps/cli/src/lib/loro/machine-document-capabilities.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { type AcpConfigOptionSummary, + type AgentConfigCliType, type AgentConfigId, getAcpCapabilityCacheKey, getMachineFlockAcpCapabilities, @@ -11,7 +12,7 @@ import { type WorkspaceId, } from '@lody/shared'; import type { LoroRepo } from 'loro-repo'; -import { MachineDocument } from './doc'; +import { type AcpCapabilityCatalogWrite, MachineDocument } from './doc'; class FakeMachineFlock implements MachineFlockWritableFlock { readonly rows = new Map(); @@ -217,13 +218,15 @@ describe('MachineDocument ACP capabilities', () => { document: MachineDocument, options: { sourceVersion?: string; - configOptionsByModel?: Record; + cliType?: AgentConfigCliType; + agentType?: string; + configOptionsByModel?: AcpCapabilityCatalogWrite; } = {} ) => document.updateAcpCapabilities( 'config-1' as AgentConfigId, - 'builtin', - 'codex', + options.cliType ?? 'builtin', + options.agentType ?? 'codex', [{ id: 'agent', name: 'Agent' }], [{ modelId: 'gpt-5', name: 'GPT-5' }], undefined, @@ -254,7 +257,7 @@ describe('MachineDocument ACP capabilities', () => { expect(readStoredCapability(flock)?.configOptionsByModel).toEqual(catalog); }); - it('drops stored configOptionsByModel when sourceVersion changes and the write omits it', async () => { + it('keeps stored configOptionsByModel when sourceVersion changes and the write omits it', async () => { const { document, flock } = createCapabilityDocument(); await writeCapabilities(document, { configOptionsByModel: catalog }); @@ -262,7 +265,83 @@ describe('MachineDocument ACP capabilities', () => { const stored = readStoredCapability(flock); expect(stored?.sourceVersion).toBe('builtin:codex:next'); - expect(stored).not.toHaveProperty('configOptionsByModel'); + expect(stored?.configOptionsByModel).toEqual(catalog); + }); + + it('clears stored configOptionsByModel when the write passes null', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + await writeCapabilities(document, { configOptionsByModel: null }); + + const stored = readStoredCapability(flock); + expect(stored).toBeDefined(); + expect(stored?.configOptionsByModel).toBeUndefined(); + expect(stored && 'configOptionsByModel' in stored).toBe(false); + expect(stored).toMatchObject({ + cliType: 'builtin', + agentType: 'codex', + sourceVersion: 'builtin:codex:test', + modes: [{ id: 'agent', name: 'Agent' }], + models: [{ modelId: 'gpt-5', name: 'GPT-5' }], + availableCommands: [{ name: '/help', description: 'Help' }], + sessionFork: false, + acknowledgedSteer: true, + }); + }); + + it('clears stored configOptionsByModel with null across a sourceVersion change', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + await writeCapabilities(document, { + sourceVersion: 'builtin:codex:next', + configOptionsByModel: null, + }); + + const stored = readStoredCapability(flock); + expect(stored?.sourceVersion).toBe('builtin:codex:next'); + expect(stored?.configOptionsByModel).toBeUndefined(); + expect(stored && 'configOptionsByModel' in stored).toBe(false); + }); + + it('does not resurrect a cleared catalog on a later write that omits the field', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + await writeCapabilities(document, { configOptionsByModel: null }); + await writeCapabilities(document); + + const stored = readStoredCapability(flock); + expect(stored?.configOptionsByModel).toBeUndefined(); + expect(stored && 'configOptionsByModel' in stored).toBe(false); + }); + + it('does not inherit a stored catalog when the CLI/agent identity changes', async () => { + const { document, flock } = createCapabilityDocument(); + + await writeCapabilities(document, { configOptionsByModel: catalog }); + await writeCapabilities(document, { cliType: 'registry', agentType: 'cursor' }); + + const stored = readStoredCapability(flock); + expect(stored?.cliType).toBe('registry'); + expect(stored?.agentType).toBe('cursor'); + expect(stored?.configOptionsByModel).toBeUndefined(); + expect(stored && 'configOptionsByModel' in stored).toBe(false); + }); + + it('treats null on a row without a catalog as an unchanged write', 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: null }); + expect(flock.commits).toBe(1); + expect(flush).toHaveBeenCalledTimes(1); + expect(markDirty).toHaveBeenCalledTimes(1); + expect(readStoredCapability(flock)).not.toHaveProperty('configOptionsByModel'); }); it('does not skip a catalog-only change and skips an identical catalog rewrite', async () => { diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 03ecdadf6..ad0ecdb52 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -74,7 +74,6 @@ import { MachineUpgradeRequestValidated, MachineUpgradeResponse, MachineAcpCapabilitiesRefreshRequestValidated, - MachineAcpCapabilitiesRefreshResponse, type MachineAcpAuthenticateRequestValidated, type MachineAcpAuthenticateResponse, SessionCodeCollabHostStartRequestValidated, @@ -119,7 +118,6 @@ import { SESSION_FILE_MAX_SIZE_BYTES, SESSION_FILE_PART_SIZE_BYTES, SESSION_FILE_PREVIEW_SNIFF_BYTES, - type AcpConfigOptionSummary, SESSION_IMAGE_ALLOWED_MIME_TYPES, SESSION_IMAGE_MAX_COUNT, SESSION_IMAGE_MAX_SIZE_BYTES, @@ -284,7 +282,11 @@ import { type ACPUpdateTarget, type BufferedACPUpdate, } from '@/lib/session-transient-store'; -import { fetchAcpCapabilities, type FetchAcpCapabilitiesOptions } from '@/agent/acp-capabilities'; +import { + fetchAcpCapabilities, + type FetchAcpCapabilitiesOptions, + type FetchedAcpCapabilities, +} from '@/agent/acp-capabilities'; import type { WorkspaceWatchCoordinatorApi } from './code-collab/workspace-watch-coordinator'; import { appendIssuePrMentionsToPrompt } from '@/session/session-execution-helpers'; import { @@ -8391,16 +8393,7 @@ export class MessageHandler { customAcp?: CustomAcpLaunchSpec, runtimeOverrides?: BuiltinRuntimeOverrides, options?: FetchAcpCapabilitiesOptions - ): Promise<{ - modes: NonNullable; - models: NonNullable; - configOptions?: AcpConfigOptionSummary[]; - availableCommands?: NonNullable; - sessionFork: boolean; - acknowledgedSteer: boolean; - modelReasoningEfforts?: Record; - capabilitySourceVersion?: string; - }> { + ): Promise { return fetchAcpCapabilities( cliType, agentType, diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index c96ca06e1..7bcadf1b1 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -55,6 +55,7 @@ import { hasBuiltinRuntimeOverrideValues, getManagedBuiltinRuntimeByAgentType, getManagedBuiltinRuntimeByRuntimeName, + isRegistryCursorAgent, serializeCustomAcpLaunchSpec, } from '@lody/shared'; import type { ContentBlock } from '@agentclientprotocol/sdk'; @@ -87,6 +88,7 @@ import { type ManagedRuntimeName, } from '@/agent/managed-agent-runtime'; import type { FetchAcpCapabilitiesOptions } from '@/agent/acp-capabilities'; +import { fetchCursorModelCatalog } from '@/agent/cursor-acp'; import { AcpAuthenticationRequiredError, AgentSteerNotDeliveredError } from '@/agent/agent-client'; import { AcpAuthenticationManager, @@ -99,7 +101,11 @@ import { captureCli } from '@/lib/analytics/posthog'; import type { SessionActivePresencePhase } from '@/lib/loro/session-active-presence'; import type { SessionConfig } from './types'; import type { ISession, SessionManager } from './session-manager'; -import type { LoroDocumentManager, SessionDocument } from '@/lib/loro/doc'; +import type { + AcpCapabilityCatalogWrite, + LoroDocumentManager, + SessionDocument, +} from '@/lib/loro/doc'; import { buildPrompt, normalizeSessionInputBlocks } from './session-execution-helpers'; import type { MemoryPressureEvictionResult } from '@/lib/session-gc-manager'; import { resolveResumableAcpSessionId } from './session-dispatch-logic'; @@ -510,7 +516,7 @@ export type SessionExecutionServiceDeps = { modes: NonNullable; models: NonNullable; configOptions?: AcpConfigOptionSummary[]; - configOptionsByModel?: Record; + configOptionsByModel?: AcpCapabilityCatalogWrite; availableCommands?: AcpCommandSummary[]; sessionFork: boolean; acknowledgedSteer: boolean; @@ -4906,6 +4912,28 @@ export class SessionExecutionService { : existing?.sourceVersion === sourceVersion ? existing.availableCommands : undefined; + let configOptionsByModel: AcpCapabilityCatalogWrite | undefined; + if ( + isRegistryCursorAgent({ + cliType: config.agentCliType, + agentType: config.agentType, + }) && + session.agentClient !== null + ) { + try { + const catalog = await fetchCursorModelCatalog({ + client: session.agentClient, + logger: this.deps.logger, + }); + configOptionsByModel = catalog ?? null; + } catch (error: unknown) { + this.deps.logger.debug( + `[${session.sessionId}] Keeping the stored Cursor model catalog: ${formatErrorMessage( + error + )}` + ); + } + } await this.deps.workspaceDocument.updateAcpCapabilities( this.deps.machineId, agentConfigId, @@ -4918,7 +4946,8 @@ export class SessionExecutionService { capabilities.sessionFork, sourceVersion, capabilities.modelReasoningEfforts, - capabilities.acknowledgedSteer + capabilities.acknowledgedSteer, + configOptionsByModel !== undefined ? { configOptionsByModel } : {} ); })().catch((error: unknown) => { this.deps.logger.debug( diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 20170acd4..6a50242af 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -29,6 +29,7 @@ import type { LoroDocumentManager } from '../src/lib/loro/doc'; import { AcpAuthenticationRequiredError, AgentSteerNotDeliveredError, + type AgentClient, } from '../src/agent/agent-client'; import { AcpAuthenticationManager } from '../src/agent/acp-authentication'; import { GitExecutableNotFoundError } from '../src/session/worktree/git-process-error'; @@ -209,6 +210,169 @@ const createBaseDeps = ( return deps; }; +const createdSessionCursorCatalogPayload = { + models: [ + { + value: 'model-full', + name: 'Full', + configOptions: [ + { + type: 'select' as const, + id: 'model', + name: 'Model', + category: 'model', + currentValue: 'model-full', + options: [{ value: 'model-full', name: 'Full' }], + }, + { + type: 'select' as const, + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + currentValue: 'true', + options: [{ value: 'true', name: 'On' }], + }, + ], + }, + ], +}; + +const createdSessionCursorCatalog = { + 'model-full': [ + { + id: 'thinking', + name: 'Thinking', + description: undefined, + category: 'thought_level', + type: 'select' as const, + currentValue: 'true', + options: [{ value: 'true', name: 'On', description: undefined }], + }, + ], +}; + +const createdSessionCapabilitySnapshot = { + modes: [{ id: 'agent', name: 'Agent' }], + models: [{ modelId: 'gpt-5', name: 'GPT-5' }], + configOptions: [ + { + id: 'reasoning', + name: 'Reasoning', + category: 'thought_level', + type: 'select' as const, + currentValue: 'high', + options: [{ value: 'high', name: 'High' }], + }, + ], + availableCommands: [{ name: 'review', description: 'Review changes' }], + sessionFork: false, + acknowledgedSteer: true, +}; + +const startCreatedSessionForCatalogWrite = async (options: { + cliType: 'registry' | 'builtin'; + agentType: string; + requestExtMethod: AgentClient['requestExtMethod']; +}) => { + const sessionId = `session-catalog-${options.cliType}-${options.agentType}` as SessionId; + const acpSessionId = `acp-catalog-${options.cliType}-${options.agentType}` as ACPSessionId; + const prompt = vi.fn(async () => ({})); + const agentClient = { + isCreated: vi.fn(() => true), + cancel: vi.fn(async () => {}), + prompt, + currentModel: undefined, + requestExtMethod: options.requestExtMethod, + }; + const createdSession = { + sessionId, + acpSessionId, + agentClient, + getAcpCapabilities: () => createdSessionCapabilitySnapshot, + terminalManager: {} as unknown, + getWorkdir: () => '/tmp', + getHostWorkdir: () => '/tmp', + getParentSessionId: () => undefined, + exec: vi.fn(async () => ''), + terminate: vi.fn(async () => {}), + updateGitIdentity: vi.fn(), + createAgent: vi.fn(async () => acpSessionId), + applyExecutionPlaneLimits: vi.fn(async () => {}), + }; + const updateAcpCapabilities = vi.fn(async () => {}); + const sessionDoc = { + getMetaState: vi.fn(async () => ({ agentConfigId: capabilityConfigId })), + getHistory: vi.fn(async () => []), + setStatus: vi.fn(async () => {}), + setProject: vi.fn(async () => {}), + setBaseBranch: vi.fn(async () => {}), + updateHistory: vi.fn(async () => {}), + roomId: `session-${sessionId}`, + }; + const deps = createBaseDeps({ + sessionManager: { + getSession: vi.fn(() => null), + getPendingSession: vi.fn(() => null), + createSession: vi.fn(async () => createdSession as unknown), + setSessionError: vi.fn(), + terminateSession: vi.fn(), + refreshGhTokenForSession: vi.fn(async () => {}), + } as unknown as SessionManager, + workspaceDocument: { + repo: { + upsertDocMeta: vi.fn(async () => {}), + getDocMeta: vi.fn(async () => undefined), + }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getAcpCapabilities: vi.fn(async () => undefined), + updateAcpCapabilities, + } as unknown as LoroDocumentManager, + }); + + const service = new SessionExecutionService(deps); + await service.startSession({ + type: 'session/create', + sessionId, + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + project: undefined, + acpSessionConfig: { + prompt: 'hello', + cliType: options.cliType, + agentType: options.agentType, + }, + userTurnId: `turn-${sessionId}`, + userId: 'user-2', + userName: 'User 2', + userEmail: 'user2@example.com', + }); + + return { updateAcpCapabilities, prompt, requestExtMethod: options.requestExtMethod }; +}; + +const expectCreatedSessionCapabilityWrite = ( + updateAcpCapabilities: ReturnType, + cliType: 'registry' | 'builtin', + agentType: string, + options: { configOptionsByModel?: unknown } | Record +) => { + expect(updateAcpCapabilities).toHaveBeenCalledWith( + 'machine-1', + capabilityConfigId, + cliType, + agentType, + createdSessionCapabilitySnapshot.modes, + createdSessionCapabilitySnapshot.models, + createdSessionCapabilitySnapshot.configOptions, + createdSessionCapabilitySnapshot.availableCommands, + false, + expect.any(String), + undefined, + true, + options + ); +}; + describe('SessionExecutionService', () => { it('advances one session owner through consecutive prompt handoffs', async () => { const steerPrompt = vi.fn(() => ({ @@ -2080,11 +2244,92 @@ describe('SessionExecutionService', () => { // Per-model reasoning efforts: absent for this agent, which publishes no // legacy `model[effort]` combination list. undefined, - true + true, + {} ) ); }); + it('fetches the Cursor model catalog from a created session without blocking the first prompt', async () => { + const catalogFetch = createDeferred>(); + const requestExtMethod = vi.fn(async () => catalogFetch.promise); + const { updateAcpCapabilities, prompt } = await startCreatedSessionForCatalogWrite({ + cliType: 'registry', + agentType: 'cursor', + requestExtMethod, + }); + + expect(prompt).toHaveBeenCalled(); + expect(updateAcpCapabilities).not.toHaveBeenCalled(); + + catalogFetch.resolve(createdSessionCursorCatalogPayload); + await vi.waitFor(() => + expectCreatedSessionCapabilityWrite(updateAcpCapabilities, 'registry', 'cursor', { + configOptionsByModel: createdSessionCursorCatalog, + }) + ); + }); + + it('clears the stored Cursor model catalog when a created session reports method not found', async () => { + const requestExtMethod = vi.fn(async () => { + throw Object.assign(new Error('Method not found'), { code: -32601 }); + }); + const { updateAcpCapabilities } = await startCreatedSessionForCatalogWrite({ + cliType: 'registry', + agentType: 'cursor', + requestExtMethod, + }); + + await vi.waitFor(() => + expectCreatedSessionCapabilityWrite(updateAcpCapabilities, 'registry', 'cursor', { + configOptionsByModel: null, + }) + ); + }); + + it('keeps the stored Cursor model catalog when a created session catalog fetch fails', async () => { + const requestExtMethod = vi.fn(async () => { + throw new Error('catalog unavailable'); + }); + const { updateAcpCapabilities } = await startCreatedSessionForCatalogWrite({ + cliType: 'registry', + agentType: 'cursor', + requestExtMethod, + }); + + await vi.waitFor(() => + expectCreatedSessionCapabilityWrite(updateAcpCapabilities, 'registry', 'cursor', {}) + ); + const failedFetchOptions = updateAcpCapabilities.mock.calls[0]?.[12]; + expect( + failedFetchOptions === undefined || + (typeof failedFetchOptions === 'object' && + failedFetchOptions !== null && + !Object.hasOwn(failedFetchOptions, 'configOptionsByModel')) + ).toBe(true); + }); + + it('does not fetch a Cursor model catalog for a created non-Cursor session', async () => { + const requestExtMethod = vi.fn(async () => createdSessionCursorCatalogPayload); + const { updateAcpCapabilities } = await startCreatedSessionForCatalogWrite({ + cliType: 'builtin', + agentType: 'codex', + requestExtMethod, + }); + + await vi.waitFor(() => + expectCreatedSessionCapabilityWrite(updateAcpCapabilities, 'builtin', 'codex', {}) + ); + expect(requestExtMethod).not.toHaveBeenCalled(); + const nonCursorWriteOptions = updateAcpCapabilities.mock.calls[0]?.[12]; + expect( + nonCursorWriteOptions === undefined || + (typeof nonCursorWriteOptions === 'object' && + nonCursorWriteOptions !== null && + !Object.hasOwn(nonCursorWriteOptions, 'configOptionsByModel')) + ).toBe(true); + }); + it('rejects session creation before spawning an agent when memory pressure persists', async () => { let history: Array> = [ { @@ -5813,6 +6058,79 @@ describe('SessionExecutionService', () => { ); }); + it('forwards a confirmed missing Cursor model catalog as a null capability write', async () => { + const capability = { + cliType: 'registry' as const, + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime' as const, + sourceVersion: 'registry:cursor:unknown', + modes: [], + models: [{ modelId: 'auto', name: 'Auto' }], + sessionFork: false, + acknowledgedSteer: false, + sessionForkWorktree: false, + fetchedAt: 1, + }; + const updateAcpCapabilities = vi.fn(async () => capability); + const fetchAcpCapabilities = vi.fn(async () => ({ + modes: [], + models: capability.models, + configOptionsByModel: null, + sessionFork: false, + acknowledgedSteer: false, + })); + + const deps = createBaseDeps({ + workspaceDocument: { + repo: { + upsertDocMeta: vi.fn(async () => {}), + getDocMeta: vi.fn(async () => undefined), + }, + getOrCreateSessionDoc: vi.fn(), + updateAcpCapabilities, + getAgentConfigForMachineLaunch: vi.fn(async () => + createLaunchConfig({ + agentType: 'cursor', + }) + ), + } as unknown as LoroDocumentManager, + fetchAcpCapabilities, + }); + + const service = new SessionExecutionService(deps); + const result = await service.refreshMachineAcpCapabilities({ + type: 'machine/acp-capabilities-refresh', + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + configId: capabilityConfigId, + }); + + expect(updateAcpCapabilities).toHaveBeenCalledWith( + 'machine-1', + capabilityConfigId, + 'registry', + 'cursor', + [], + capability.models, + undefined, + undefined, + false, + expect.any(String), + undefined, + false, + expect.objectContaining({ configOptionsByModel: null }) + ); + expect(result).toEqual( + expect.objectContaining({ + type: 'machine/acp-capabilities-refresh_response', + success: true, + capability, + }) + ); + expect(result.capability).not.toEqual(expect.objectContaining({ configOptionsByModel: null })); + }); + it('deduplicates concurrent ACP capability refreshes for the same config and launch inputs', async () => { let release: () => void = () => {}; const fetched = new Promise((resolve) => { From e63f788271340a9c8106f03230b97cb7276b7f37 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 16:55:02 +0800 Subject: [PATCH 08/13] fix(components): settle a pending Role restore on explicit user selection useRestoreChatLandingAgentRole keeps the saved Role pending while its availability is unknown so the persist effect does not write null for it. Nothing ended that pending state when the user picked another Role, cleared it, chose a recent run config, changed the agent config or provider, or changed the machine, so the deferred restore fired once capabilities arrived and overwrote the user's choice with the previously saved Role. Every user-driven selection entry point in chat-landing now completes restoration first, which also lets the persist effect record that selection. Refs #343 Model: claude-fable-5-1-thinking-max --- packages/components/src/AGENTS.md | 4 +- .../src/components/chat/chat-landing.tsx | 39 +++++++++++--- .../src/hooks/use-chat-landing-defaults.ts | 7 ++- .../tests/use-chat-landing-defaults.test.tsx | 51 ++++++++++++++++++- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index 20209f650..308b27f3a 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -62,7 +62,9 @@ Parent `AGENTS.md` files also apply. that is no longer advertised keeps the Role listed with its reason and removes it from mention and composer selection. Missing or stale capability data stays unknown; never migrate a saved selection or substitute the agent's default to make it available. - Restoring the last-used Role retains its saved id while availability is unknown. + Restoring the last-used Role retains its saved id while availability is unknown, + and an explicit user selection during that window ends the pending restore instead + of being overwritten by it. ## ACP authentication diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 5a6b4ceb2..346415d34 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -1620,6 +1620,16 @@ function WorkspaceChatLanding({ /* Whether the stored Role has been resolved yet. Until it has, the composer has no opinion to persist — see `selectedAgentRoleId` on the defaults hook. */ const [agentRoleRestored, setAgentRoleRestored] = useState(false); + /* An explicit user selection ends a pending restore so the deferred `onSelect` + cannot overwrite it, and lets the persist effect record the user's choice. */ + const settleAgentRoleRestore = useCallback(() => setAgentRoleRestored(true), []); + const handleUserAgentConfigChange = useCallback( + (selection: AgentSelection) => { + settleAgentRoleRestore(); + setSelectedAgent(selection); + }, + [settleAgentRoleRestore] + ); /* The Role editor is a Dialog, so it is hosted OUT here rather than inside the run-config dropdown: a Dialog rendered in menu content unmounts with the menu the moment it opens. */ @@ -1898,7 +1908,10 @@ function WorkspaceChatLanding({ const nextSelection = cycleProviderSelections.find( (selection) => selection.agentId === agentId ); - if (nextSelection) setSelectedAgent(nextSelection); + if (nextSelection) { + settleAgentRoleRestore(); + setSelectedAgent(nextSelection); + } }, } : null, @@ -1945,6 +1958,7 @@ function WorkspaceChatLanding({ // ── Handle explicit machine change: auto-select an agent owned by the new machine ── const handleMachineChange = useCallback( (machineId: MachineId) => { + settleAgentRoleRestore(); machineChangedByUserRef.current = true; setSelectedMachineId(machineId); if (contextType === 'local') { @@ -1975,7 +1989,13 @@ function WorkspaceChatLanding({ setSelectedAgent(null); } }, - [contextType, executorConfigs, handleSelectedLocalProjectChange, selectedAgent] + [ + contextType, + executorConfigs, + handleSelectedLocalProjectChange, + selectedAgent, + settleAgentRoleRestore, + ] ); const createNewMachinePairing = useCallback(async () => { @@ -3524,6 +3544,7 @@ function WorkspaceChatLanding({ ); const handleAgentRoleSelect = useCallback( (roleId: AgentRoleId | null) => { + settleAgentRoleRestore(); // Leaving a Role clears the NAME, not the configuration: the values it // seeded are now the user's own, and silently rolling them back would // undo choices they never asked to undo. @@ -3541,7 +3562,7 @@ function WorkspaceChatLanding({ setSelectedAgent({ agentId: role.agentConfigId, machineId: role.machineId }); setAgentRolePreference({ roleId: role.id, token: agentRolePreferenceTokenRef.current }); }, - [composerAgentRoleItems] + [composerAgentRoleItems, settleAgentRoleRestore] ); /* Creating a Role from the composer opens on the configuration already in @@ -3653,6 +3674,7 @@ function WorkspaceChatLanding({ ); const handleRecentRunConfigSelect = useCallback( (id: string) => { + settleAgentRoleRestore(); const record = recentRunConfigRecords.find((entry) => getRecentRunConfigKey(entry) === id); if (!record) return; // Recorded AS a Role: re-apply the Role, not the values it set. Those @@ -3672,7 +3694,12 @@ function WorkspaceChatLanding({ setSelectedAgent({ agentId: config.id, machineId: config.machineId }); setPendingRecentRunConfig(record); }, - [handleAgentRoleSelect, recentRunConfigAgentConfigs, recentRunConfigRecords] + [ + handleAgentRoleSelect, + recentRunConfigAgentConfigs, + recentRunConfigRecords, + settleAgentRoleRestore, + ] ); const desktopMachineOptions = useMemo( @@ -3773,7 +3800,7 @@ function WorkspaceChatLanding({ cliType: selectedConfig?.cliType, agentType: selectedConfig?.agentType, }} - onAgentConfigChange={setSelectedAgent} + onAgentConfigChange={handleUserAgentConfigChange} modelOptions={modelOptions} selectedModelId={selectedModelId} onModelChange={setSelectedModelName} @@ -4076,7 +4103,7 @@ function WorkspaceChatLanding({ agentSelection={selectedAgent} allowedMachineIds={scopedMachineId ? [scopedMachineId] : []} agentLocked={false} - onAgentConfigChange={setSelectedAgent} + onAgentConfigChange={handleUserAgentConfigChange} modelOptions={modelOptions} selectedModelId={selectedModelId} onModelChange={setSelectedModelName} diff --git a/packages/components/src/hooks/use-chat-landing-defaults.ts b/packages/components/src/hooks/use-chat-landing-defaults.ts index f730d586d..4e2df8e39 100644 --- a/packages/components/src/hooks/use-chat-landing-defaults.ts +++ b/packages/components/src/hooks/use-chat-landing-defaults.ts @@ -15,7 +15,12 @@ import { } from '@/lib/chat-landing-defaults'; type LocalProjectSelection = { machineId: MachineId; localProjectId: LocalProjectId }; -/** Restore only after both the catalog and the bound agent's capabilities can answer. */ +/** + * Restore only after both the catalog and the bound agent's capabilities can answer. + * The caller must complete restoration (`setRestored(true)`) on any explicit user + * selection while the stored Role is still unknown; otherwise the deferred + * `onSelect` would overwrite that selection. + */ export function useRestoreChatLandingAgentRole({ workspaceId, defaultsReady, diff --git a/packages/components/tests/use-chat-landing-defaults.test.tsx b/packages/components/tests/use-chat-landing-defaults.test.tsx index f0908f1ec..054980f06 100644 --- a/packages/components/tests/use-chat-landing-defaults.test.tsx +++ b/packages/components/tests/use-chat-landing-defaults.test.tsx @@ -118,6 +118,7 @@ describe('chat landing agent session defaults', () => { describe('chat landing saved Role restoration', () => { const workspaceId = 'ws-role-restoration'; + const otherRoleId = 'other-role' as AgentRoleId; const savedRole: AgentRole = { v: 1, id: 'saved-role' as AgentRoleId, @@ -170,7 +171,31 @@ describe('chat landing saved Role restoration', () => { catalogSynced: true, onSelect: setSelectedRoleId, }); - return ; + return ( + <> + + + + + ); } function render(availability: ComposerAgentRoleItem['availability']) { @@ -211,6 +236,30 @@ describe('chat landing saved Role restoration', () => { expect(container.querySelector('output')?.dataset.role).toBe(''); expect(readChatLandingDefaults(workspaceId)?.agentRoleId).toBeNull(); }); + + it('keeps a Role the user picked while the saved Role was still unknown', () => { + render({ kind: 'unknown' }); + act(() => { + container.querySelector('[data-action="pick-other"]')?.click(); + }); + expect(container.querySelector('output')?.dataset.restored).toBe('true'); + expect(container.querySelector('output')?.dataset.role).toBe(otherRoleId); + expect(readChatLandingDefaults(workspaceId)?.agentRoleId).toBe(otherRoleId); + + render({ kind: 'available' }); + expect(container.querySelector('output')?.dataset.role).toBe(otherRoleId); + expect(readChatLandingDefaults(workspaceId)?.agentRoleId).toBe(otherRoleId); + }); + + it('keeps a cleared Role selection made while the saved Role was still unknown', () => { + render({ kind: 'unknown' }); + act(() => { + container.querySelector('[data-action="clear"]')?.click(); + }); + render({ kind: 'available' }); + expect(container.querySelector('output')?.dataset.role).toBe(''); + expect(readChatLandingDefaults(workspaceId)?.agentRoleId).toBeNull(); + }); }); /* The #185 oscillation regression (session 51e236e0…) lives in From a40fc541032de89fbbf8c969d47eab5c89dd101f Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 17:08:28 +0800 Subject: [PATCH 09/13] feat(shared): map MCP run config onto the target model's option catalog lody_session_create takes reasoningEffort and fastMode semantically, and the mapping onto an agent's option ids read only the probe snapshot: for a Cursor target it could write a reasoning level into the two-valued thinking select, reject fast mode for a model whose own catalog offers it, and publish the probed model's effort values for every model. The mapping now reads the target model's composed options: reasoning effort binds to a multi-level thought_level select when the model has one and to a lone thinking toggle otherwise, a value is validated against the target model's catalog entry and returned in validatedConfigIds, fast mode is looked up per model, and lody_session_create_options publishes each catalogued model's effort values. Agents without a catalog keep the snapshot-based unverified-selection path. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- packages/shared/src/acp-run-config.ts | 90 +++++-- packages/shared/tests/acp-run-config.test.ts | 232 +++++++++++++++++++ 2 files changed, 301 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index 4963a8aac..f22f754b6 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -49,10 +49,7 @@ export const isAcpToggleSelectValues = (values: readonly string[]): boolean => export const isAcpToggleSelectEnabledValue = (value: unknown): boolean => value === ACP_CONFIG_OPTION_ON_VALUE || value === ACP_CONFIG_OPTION_TRUE_VALUE; -export const toggleAcpSelectOptionValue = ( - values: readonly string[], - enabled: boolean -): string => +export const toggleAcpSelectOptionValue = (values: readonly string[], enabled: boolean): string => isAcpTrueFalseSelectValues(values) ? enabled ? ACP_CONFIG_OPTION_TRUE_VALUE @@ -94,7 +91,8 @@ export type AgentRunConfigSelection = { export type AgentRunConfigCapabilities = { /** * `reasoningEffortValues` is per model when the agent publishes that - * breakdown; otherwise it is absent and only the snapshot below applies. + * breakdown or a per-model option catalog; otherwise it is absent and only + * the snapshot below applies. */ models: Array<{ id: string; name: string; reasoningEffortValues?: string[] }>; /** @@ -130,7 +128,7 @@ export type AgentRunConfigResolution = { type RunConfigCapabilitySource = Pick< AcpCapabilityCacheEntry, - 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' + 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' | 'configOptionsByModel' >; /** @@ -192,20 +190,52 @@ const toggleValue = (option: AcpConfigOptionSummary, enabled: boolean): AcpConfi ? enabled : toggleAcpSelectOptionValue(selectOptionValues(option), enabled); +const isMultiLevelReasoningEffortSelect = (option: AcpConfigOptionSummary): boolean => + option.type === 'select' && + isAcpThoughtLevelConfigOption(option) && + !isAcpToggleSelectValues(selectOptionValues(option)); + +const isThoughtLevelSelect = (option: AcpConfigOptionSummary): boolean => + option.type === 'select' && isAcpThoughtLevelConfigOption(option); + +/** + * A model that publishes both a thinking toggle and an effort ladder (Cursor) + * maps `reasoningEffort` onto the ladder; a model whose only thought control is + * the toggle (Kimi without effort levels) keeps mapping onto it. + */ +const findReasoningEffortOptionIn = ( + configOptions: readonly AcpConfigOptionSummary[] | undefined +): AcpConfigOptionSummary | undefined => + configOptions?.find(isMultiLevelReasoningEffortSelect) ?? + configOptions?.find(isThoughtLevelSelect); + +const findFastModeOptionIn = ( + configOptions: readonly AcpConfigOptionSummary[] | undefined +): AcpConfigOptionSummary | undefined => + configOptions?.find((option) => isAcpFastModeConfigId(option.id) && isToggleOption(option)); + +const hasPerModelCatalogEntry = ( + capability: RunConfigCapabilitySource | undefined, + modelId: string | undefined +): boolean => { + const catalog = capability?.configOptionsByModel; + return catalog !== undefined && typeof modelId === 'string' && catalog[modelId] !== undefined; +}; + const findFastModeOption = ( - capability: RunConfigCapabilitySource | undefined + capability: RunConfigCapabilitySource | undefined, + targetModelId?: string ): AcpConfigOptionSummary | undefined => - findConfigOption( - capability, - (option) => isAcpFastModeConfigId(option.id) && isToggleOption(option) + findFastModeOptionIn( + capability ? resolveAcpConfigOptionsForModel(capability, targetModelId) : undefined ); const findReasoningEffortOption = ( - capability: RunConfigCapabilitySource | undefined + capability: RunConfigCapabilitySource | undefined, + targetModelId?: string ): AcpConfigOptionSummary | undefined => - findConfigOption( - capability, - (option) => option.type === 'select' && isAcpThoughtLevelConfigOption(option) + findReasoningEffortOptionIn( + capability ? resolveAcpConfigOptionsForModel(capability, targetModelId) : undefined ); const findPlanModeOption = ( @@ -275,8 +305,20 @@ export const summarizeAgentRunConfigCapabilities = ( const measuredForModelId = findCurrentModelId(capability); return { models: listModels(capability).map((model) => { - const efforts = perModelEfforts?.[model.id]; - return { ...model, ...(efforts ? { reasoningEffortValues: efforts } : {}) }; + const legacyEfforts = perModelEfforts?.[model.id]; + if (legacyEfforts) { + return { ...model, reasoningEffortValues: legacyEfforts }; + } + if (!capability || !hasPerModelCatalogEntry(capability, model.id)) { + return model; + } + const catalogEfforts = findReasoningEffortOptionIn( + resolveAcpConfigOptionsForModel(capability, model.id) + )?.options.map((value) => value.value); + return { + ...model, + ...(catalogEfforts ? { reasoningEffortValues: catalogEfforts } : {}), + }; }), reasoningEffortValues: (findReasoningEffortOption(capability)?.options ?? []).map( (value) => value.value @@ -327,7 +369,7 @@ export const resolveAgentRunConfigSelection = ( let modeId: string | undefined; if (selection.reasoningEffort !== undefined) { - const option = findReasoningEffortOption(capability); + const option = findReasoningEffortOption(capability, targetModelId); const targetModelEfforts = targetModelId ? capability.modelReasoningEfforts?.[targetModelId] : undefined; @@ -344,6 +386,14 @@ export const resolveAgentRunConfigSelection = ( // Validated against the target model; the caller's snapshot check knows // only the probed model's list and could reject a legitimate value. validatedConfigIds.push(configId); + } else if (option && hasPerModelCatalogEntry(capability, targetModelId)) { + const allowed = option.options.map((value) => value.value); + if (!allowed.includes(selection.reasoningEffort)) { + throw new Error( + `Invalid reasoning effort for model ${targetModelId}: ${selection.reasoningEffort}. Allowed values: ${allowed.join(', ')}.` + ); + } + validatedConfigIds.push(configId); } else if (switchesModel) { unverifiedSelections.push(`reasoningEffort=${selection.reasoningEffort}`); validatedConfigIds.push(configId); @@ -352,14 +402,12 @@ export const resolveAgentRunConfigSelection = ( } if (selection.fastMode !== undefined) { - const option = findFastModeOption(capability); + const option = findFastModeOption(capability, targetModelId); if (!option) { throw new Error('The selected agent does not offer a fast mode option.'); } configOptionValues[option.id] = toggleValue(option, selection.fastMode); - if (switchesModel) { - // Agents drop the fast toggle entirely for models that lack fast support, - // and no agent publishes which models those are. + if (switchesModel && !hasPerModelCatalogEntry(capability, targetModelId)) { unverifiedSelections.push(`fastMode=${selection.fastMode}`); } } diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 3c368d939..715008add 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -641,3 +641,235 @@ describe('ACP toggle select predicates', () => { expect(isAcpToggleSelectEnabledValue(true)).toBe(false); }); }); + +const cursorThinkingSelect = (): AcpConfigOptionSummary => ({ + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorEffortSelect = ( + values: readonly string[], + currentValue: string +): AcpConfigOptionSummary => ({ + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue, + options: values.map((value) => ({ value, name: value })), +}); + +const cursorFastSelect = (): AcpConfigOptionSummary => ({ + id: 'fast', + name: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorContextSelect = (): AcpConfigOptionSummary => ({ + id: 'context', + name: 'Context', + category: 'model_config', + type: 'select', + currentValue: 'default', + options: [{ value: 'default', name: 'Default' }], +}); + +const cursorReasoningSelect = (): AcpConfigOptionSummary => ({ + id: 'reasoning', + name: 'Reasoning', + category: 'thought_level', + type: 'select', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + { value: 'extra-high', name: 'Extra high' }, + ], +}); + +const cursorModelSelect = (): AcpConfigOptionSummary => ({ + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'opus', + options: [ + { value: 'opus', name: 'Opus' }, + { value: 'sonnet', name: 'Sonnet' }, + { value: 'gemini', name: 'Gemini' }, + { value: 'gpt', name: 'GPT' }, + { value: 'empty', name: 'Empty' }, + ], +}); + +const cursorOpusSnapshotOptions = (): AcpConfigOptionSummary[] => [ + cursorModelSelect(), + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), +]; + +const cursorCatalogCapability = (): AcpCapabilityCacheEntry => ({ + cliType: 'custom', + agentType: 'cursor', + modes: [], + models: [], + configOptions: cursorOpusSnapshotOptions(), + configOptionsByModel: { + opus: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), + ], + sonnet: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'max'], 'medium'), + ], + gemini: [cursorEffortSelect(['minimal', 'low', 'medium', 'high'], 'medium')], + gpt: [cursorReasoningSelect(), cursorFastSelect()], + empty: [], + }, + fetchedAt: 1, +}); + +describe('per-model config catalog run config', () => { + it('writes gpt extra-high onto reasoning and marks that id pre-validated', () => { + const resolved = resolveAgentRunConfigSelection( + { modelId: 'gpt', reasoningEffort: 'extra-high' }, + cursorCatalogCapability() + ); + expect(resolved.configOptionValues).toEqual({ reasoning: 'extra-high' }); + expect(resolved.configOptionValues).not.toHaveProperty('thinking'); + expect(resolved.validatedConfigIds).toEqual(['reasoning']); + }); + + it('rejects a reasoning effort the target model catalog does not allow', () => { + expect(() => + resolveAgentRunConfigSelection( + { modelId: 'gemini', reasoningEffort: 'max' }, + cursorCatalogCapability() + ) + ).toThrow('Invalid reasoning effort for model gemini'); + }); + + it('rejects fast mode when the target catalog omits it and accepts gpt without unverifiedSelections', () => { + expect(() => + resolveAgentRunConfigSelection( + { modelId: 'sonnet', fastMode: true }, + cursorCatalogCapability() + ) + ).toThrow('does not offer a fast mode option'); + + const resolved = resolveAgentRunConfigSelection( + { modelId: 'gpt', fastMode: true }, + cursorCatalogCapability() + ); + expect(resolved.configOptionValues).toEqual({ fast: 'true' }); + expect(resolved.unverifiedSelections).toBeUndefined(); + }); + + it('rejects reasoning effort when the target catalog entry is empty', () => { + expect(() => + resolveAgentRunConfigSelection( + { modelId: 'empty', reasoningEffort: 'high' }, + cursorCatalogCapability() + ) + ).toThrow('does not offer a reasoning effort option'); + }); + + it('keeps snapshot-based unverified selections when the agent has no catalog', () => { + const capability: AcpCapabilityCacheEntry = { + ...cursorCatalogCapability(), + configOptionsByModel: undefined, + }; + const resolved = resolveAgentRunConfigSelection( + { modelId: 'sonnet', reasoningEffort: 'high', fastMode: true }, + capability + ); + expect(resolved.configOptionValues).toEqual({ effort: 'high', fast: 'true' }); + expect(resolved.unverifiedSelections).toEqual(['reasoningEffort=high', 'fastMode=true']); + expect(resolved.validatedConfigIds).toEqual(['effort']); + }); + + it('maps reasoning effort onto a lone thinking toggle when no effort ladder exists', () => { + const capability: AcpCapabilityCacheEntry = { + cliType: 'builtin', + agentType: 'kimi', + modes: [], + models: [], + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'k2', + options: [{ value: 'k2', name: 'K2' }], + }, + { + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'off', + options: [ + { value: 'off', name: 'Off' }, + { value: 'on', name: 'On' }, + ], + }, + ], + fetchedAt: 1, + }; + expect( + resolveAgentRunConfigSelection({ reasoningEffort: 'on' }, capability).configOptionValues + ).toEqual({ thinking: 'on' }); + expect(summarizeAgentRunConfigCapabilities(capability).reasoningEffortValues).toEqual([ + 'off', + 'on', + ]); + }); + + it('publishes per-model catalog efforts and still prefers legacy modelReasoningEfforts', () => { + const summary = summarizeAgentRunConfigCapabilities(cursorCatalogCapability()); + expect(summary.models.find((model) => model.id === 'gpt')?.reasoningEffortValues).toEqual([ + 'low', + 'medium', + 'high', + 'extra-high', + ]); + expect(summary.models.find((model) => model.id === 'gemini')?.reasoningEffortValues).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + ]); + expect( + summary.models.find((model) => model.id === 'empty')?.reasoningEffortValues + ).toBeUndefined(); + + const withLegacy = summarizeAgentRunConfigCapabilities({ + ...cursorCatalogCapability(), + modelReasoningEfforts: { gpt: ['low', 'high'] }, + }); + expect(withLegacy.models.find((model) => model.id === 'gpt')?.reasoningEffortValues).toEqual([ + 'low', + 'high', + ]); + }); +}); From 818d2fe2163df5ac4518c39c68c6c8c815ba22e8 Mon Sep 17 00:00:00 2001 From: terry Date: Thu, 3 Sep 2026 17:08:40 +0800 Subject: [PATCH 10/13] fix(cli): validate and inherit turn config options per target model CLI and MCP create/chat validated configOptionValues against the probe snapshot, so a Cursor turn targeting another model was checked against the probed model's options: a value the target model allows could be rejected as unknown, a value it lacks could pass, and inherited create defaults kept a parent's opus-only fast or effort for a child that will run sonnet. validateTurnConfigOptionValues and filterCompatibleTurnConfigOptionValues now compose the target model's options through resolveAcpConfigOptionsForModel, inherited defaults are filtered against the MERGED target model (explicit create modelId, then the inherited modelId, then the model option), and an explicit create modelId drops a parent's superseded model option so the frozen Turn names one model. Implemented with cursor-grok-4.6-xhigh-fast subagents. Model: claude-fable-5.1 Co-authored-by: Cursor --- apps/cli/src/commands/session.test.ts | 145 ++++++++++++++++++++++++++ apps/cli/src/commands/session.ts | 94 ++++++++++++++--- 2 files changed, 226 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index f2157418c..1c683ff77 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -7,6 +7,7 @@ import { getMachineFlockDocId, getSessionRoomId, type AcpCapabilityCacheEntry, + type AcpConfigOptionSummary, type AgentConfigMeta, type LocalProjectGitState, type MachineId, @@ -143,6 +144,108 @@ const createAcpCapability = (): AcpCapabilityCacheEntry => ({ fetchedAt: 1, }); +const cursorThinkingSelect = (): AcpConfigOptionSummary => ({ + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorEffortSelect = ( + values: readonly string[], + currentValue: string +): AcpConfigOptionSummary => ({ + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue, + options: values.map((value) => ({ value, name: value })), +}); + +const cursorFastSelect = (): AcpConfigOptionSummary => ({ + id: 'fast', + name: 'Fast', + category: 'model_config', + type: 'select', + currentValue: 'false', + options: [ + { value: 'false', name: 'Off' }, + { value: 'true', name: 'On' }, + ], +}); + +const cursorContextSelect = (): AcpConfigOptionSummary => ({ + id: 'context', + name: 'Context', + category: 'model_config', + type: 'select', + currentValue: 'default', + options: [{ value: 'default', name: 'Default' }], +}); + +const createCursorAcpCapability = (): AcpCapabilityCacheEntry => ({ + cliType: 'custom', + agentType: 'cursor', + modes: [], + models: [], + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'opus', + options: [ + { value: 'opus', name: 'Opus' }, + { value: 'sonnet', name: 'Sonnet' }, + { value: 'gemini', name: 'Gemini' }, + { value: 'gpt', name: 'GPT' }, + ], + }, + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), + ], + configOptionsByModel: { + opus: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + cursorContextSelect(), + ], + sonnet: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'max'], 'medium'), + ], + gemini: [cursorEffortSelect(['minimal', 'low', 'medium', 'high'], 'medium')], + gpt: [ + { + id: 'reasoning', + name: 'Reasoning', + category: 'thought_level', + type: 'select', + currentValue: 'low', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + { value: 'extra-high', name: 'Extra high' }, + ], + }, + cursorFastSelect(), + ], + empty: [], + }, + fetchedAt: 1, +}); + describe('session command helpers', () => { it('uses one hard Meta read across request validation and accepted create materialization', async () => { const syncMetaOrThrow = vi.fn(async () => undefined); @@ -559,6 +662,48 @@ describe('session command helpers', () => { ).not.toThrow(); }); + it('validates turn config option values against the target model catalog', () => { + const capability = createCursorAcpCapability(); + expect(() => + validateTurnConfigOptionValues({ effort: 'max' }, capability, undefined, 'gemini') + ).toThrow(/Allowed values/); + expect(() => + validateTurnConfigOptionValues({ fast: 'true' }, capability, undefined, 'sonnet') + ).toThrow('Unknown ACP config option for the selected agent: fast.'); + expect(() => + validateTurnConfigOptionValues({ thinking: 'true' }, capability, undefined, 'sonnet') + ).not.toThrow(); + }); + + it('drops inherited options that the explicit target model catalog rejects', () => { + expect( + filterCompatibleInheritedTurnConfig( + { modelId: 'opus', configOptionValues: { fast: 'true', effort: 'xhigh' } }, + createCursorAcpCapability(), + { targetModelId: 'sonnet' } + ) + ).toEqual({ modelId: 'opus' }); + }); + + it('filters inherited options against the inherited model when no create target is given', () => { + expect( + filterCompatibleInheritedTurnConfig( + { modelId: 'sonnet', configOptionValues: { fast: 'true' } }, + createCursorAcpCapability() + ) + ).toEqual({ modelId: 'sonnet' }); + }); + + it('removes a superseded inherited model option when create names a different model', () => { + expect( + filterCompatibleInheritedTurnConfig( + { configOptionValues: { model: 'opus', thinking: 'true' } }, + createCursorAcpCapability(), + { targetModelId: 'sonnet' } + ) + ).toEqual({ configOptionValues: { thinking: 'true' } }); + }); + it('sorts sessions with invalid createdAt timestamps deterministically', () => { const sessions = [ createSessionMeta({ diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 799a27ba3..dbb07cd89 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -49,6 +49,8 @@ import { isMachineDocRoomId, isSessionDocRoomId, hasAgentRunConfigSelection, + resolveAcpConfigOptionsForModel, + resolveAcpTargetModelId, resolveAgentRunConfigSelection, resolveBaseBranchPreference, resolveProjectGitHubRepo, @@ -1482,16 +1484,25 @@ export function validateTurnConfigOptionValues( * capability's `configOptions` only describe the probed model, so re-checking * them here would reject values that are valid for the target model. */ - skipIds?: ReadonlySet + skipIds?: ReadonlySet, + modelId?: string | null ): void { const entries = Object.entries(values ?? {}).filter(([id]) => !skipIds?.has(id)); if (entries.length === 0) { return; } - if (!capability?.configOptions) { + const targetModelId = resolveAcpTargetModelId({ + modelId, + configOptionValues: values, + configOptions: capability?.configOptions, + }); + const configOptions = capability + ? resolveAcpConfigOptionsForModel(capability, targetModelId) + : undefined; + if (!configOptions) { throw new Error('ACP config options are unavailable for the selected agent.'); } - const optionsById = new Map(capability.configOptions.map((option) => [option.id, option])); + const optionsById = new Map(configOptions.map((option) => [option.id, option])); for (const [id, value] of entries) { const option = optionsById.get(id); if (!option) { @@ -1506,12 +1517,21 @@ export function validateTurnConfigOptionValues( export function filterCompatibleTurnConfigOptionValues( values: Record | undefined, - capability: AcpCapabilityCacheEntry | undefined + capability: AcpCapabilityCacheEntry | undefined, + targetModelId?: string | null ): Record | undefined { - if (!values || !capability?.configOptions) { + const resolvedTargetModelId = resolveAcpTargetModelId({ + modelId: targetModelId, + configOptionValues: values, + configOptions: capability?.configOptions, + }); + const configOptions = capability + ? resolveAcpConfigOptionsForModel(capability, resolvedTargetModelId) + : undefined; + if (!values || !configOptions) { return undefined; } - const optionsById = new Map(capability.configOptions.map((option) => [option.id, option])); + const optionsById = new Map(configOptions.map((option) => [option.id, option])); const compatible = Object.fromEntries( Object.entries(values).filter(([id, value]) => { const option = optionsById.get(id); @@ -1555,18 +1575,58 @@ export function validateTurnModeAndModel( } } +/** Drop a superseded inherited `model` option so a Turn does not name two models. */ +function dropSupersededInheritedModelOption( + values: Record | undefined, + capability: AcpCapabilityCacheEntry | undefined, + explicitModelId: string | null | undefined +): Record | undefined { + if (!values) { + return undefined; + } + if (typeof explicitModelId !== 'string' || explicitModelId === '') { + return values; + } + const modelOption = capability?.configOptions?.find( + (option) => option.category === 'model' && option.type === 'select' + ); + if (modelOption === undefined) { + return values; + } + const inheritedModel = values[modelOption.id]; + if (typeof inheritedModel !== 'string' || inheritedModel === explicitModelId) { + return values; + } + const next = { ...values }; + delete next[modelOption.id]; + return Object.keys(next).length > 0 ? next : undefined; +} + export function filterCompatibleInheritedTurnConfig( config: ResolvedTurnDispatchConfig | undefined, - capability: AcpCapabilityCacheEntry | undefined + capability: AcpCapabilityCacheEntry | undefined, + options?: { targetModelId?: string | null } ): ResolvedTurnDispatchConfig | undefined { if (!config) { return undefined; } const supportedModes = getSupportedTurnSelectorIds(capability, 'mode'); const supportedModels = getSupportedTurnSelectorIds(capability, 'model'); - const configOptionValues = filterCompatibleTurnConfigOptionValues( - config.configOptionValues, - capability + const inheritedModelId = + typeof config.modelId === 'string' && supportedModels.has(config.modelId) + ? config.modelId + : undefined; + const targetModelId = + options?.targetModelId || + inheritedModelId || + resolveAcpTargetModelId({ + configOptionValues: config.configOptionValues, + configOptions: capability?.configOptions, + }); + const configOptionValues = dropSupersededInheritedModelOption( + filterCompatibleTurnConfigOptionValues(config.configOptionValues, capability, targetModelId), + capability, + options?.targetModelId ); return { ...(config.modeId && supportedModes.has(config.modeId) ? { modeId: config.modeId } : {}), @@ -2834,13 +2894,16 @@ async function resolveEffectiveSessionCreateDispatchConfig(args: { validateTurnConfigOptionValues( requested.config.configOptionValues, capability, - requested.validatedConfigIds + requested.validatedConfigIds, + requested.config.modelId ); return { ...withBuiltinDefaultTurnMode( mergeTurnDispatchConfig( requested.config, - filterCompatibleInheritedTurnConfig(inheritedDispatchConfig, capability) + filterCompatibleInheritedTurnConfig(inheritedDispatchConfig, capability, { + targetModelId: requested.config.modelId, + }) ), args.agentConfig ), @@ -3235,7 +3298,12 @@ export async function sendSessionChatResult( agentConfigId: session.agentConfigId, }); validateTurnModeAndModel(dispatchConfig, capability); - validateTurnConfigOptionValues(dispatchConfig.configOptionValues, capability); + validateTurnConfigOptionValues( + dispatchConfig.configOptionValues, + capability, + undefined, + dispatchConfig.modelId + ); } const effectiveDispatchConfig = withBuiltinDefaultTurnMode(dispatchConfig, session); From 29499b08facde8a8b69fcf62e2a6c7f2a9430020 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 17:16:30 +0800 Subject: [PATCH 11/13] fix(cli): omit the per-model catalog from the refresh response capability machine/acp-capabilities-refresh_response.capability is parsed by clients through a strict schema, so a daemon that persists configOptionsByModel made every client older than that field reject a successful registry Cursor refresh and report a timeout. The response now carries the stored entry without the catalog; clients receive it through the Machine Flock row, whose reader tolerates unknown fields. Refs #343 Model: claude-fable-5-1-thinking-max --- .../src/session/session-execution-service.ts | 19 +++- .../tests/session-execution-service.test.ts | 93 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 7bcadf1b1..12e4531ce 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -47,6 +47,7 @@ import { buildReplayPromptFromHistory, type ReplayPromptResult, getLegacyReadForSessionHistoryStatus, + type AcpCapabilityCacheEntry, type AcpCommandSummary, type AcpConfigOptionSummary, type AcpConfigOptionValue, @@ -529,6 +530,22 @@ export type SessionExecutionServiceDeps = { const shouldRedactEnvKey = (key: string): boolean => /token|secret|password|passwd|key/i.test(key); +/** + * Clients read the refresh response's `capability` through a strict schema, so it + * carries only the fields every shipped client declares. Registry Cursor's per-model + * catalog reaches clients through the Machine Flock row instead, whose reader + * tolerates unknown fields, so a client older than the catalog still parses a + * successful refresh. + */ +const toRefreshResponseCapability = ( + entry: AcpCapabilityCacheEntry | undefined +): AcpCapabilityCacheEntry | undefined => { + if (entry?.configOptionsByModel === undefined) return entry; + const wireEntry: AcpCapabilityCacheEntry = { ...entry }; + delete wireEntry.configOptionsByModel; + return wireEntry; +}; + const redactEnvForLog = (env?: Record): Record | undefined => { if (!env) { return undefined; @@ -5318,7 +5335,7 @@ export class SessionExecutionService { category: opt.category, optionCount: opt.options.length, })), - capability, + capability: toRefreshResponseCapability(capability), availableCommands, }; } catch (error) { diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 6a50242af..edfc8582b 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -6131,6 +6131,99 @@ describe('SessionExecutionService', () => { expect(result.capability).not.toEqual(expect.objectContaining({ configOptionsByModel: null })); }); + it('omits the stored per-model catalog from the refresh response capability', async () => { + const catalog = { + 'model-a': [ + { + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + type: 'select' as const, + currentValue: 'true', + options: [{ value: 'true', name: 'On' }], + }, + ], + 'model-b': [], + }; + const storedCapability = { + cliType: 'registry' as const, + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime' as const, + sourceVersion: 'registry:cursor:unknown', + modes: [], + models: [ + { modelId: 'model-a', name: 'Model A' }, + { modelId: 'model-b', name: 'Model B' }, + ], + sessionFork: false, + acknowledgedSteer: false, + sessionForkWorktree: false, + fetchedAt: 1, + configOptionsByModel: catalog, + }; + const updateAcpCapabilities = vi.fn(async () => storedCapability); + const fetchAcpCapabilities = vi.fn(async () => ({ + modes: [], + models: storedCapability.models, + configOptionsByModel: catalog, + sessionFork: false, + acknowledgedSteer: false, + })); + + const deps = createBaseDeps({ + workspaceDocument: { + repo: { + upsertDocMeta: vi.fn(async () => {}), + getDocMeta: vi.fn(async () => undefined), + }, + getOrCreateSessionDoc: vi.fn(), + updateAcpCapabilities, + getAgentConfigForMachineLaunch: vi.fn(async () => + createLaunchConfig({ + agentType: 'cursor', + }) + ), + } as unknown as LoroDocumentManager, + fetchAcpCapabilities, + }); + + const service = new SessionExecutionService(deps); + const result = await service.refreshMachineAcpCapabilities({ + type: 'machine/acp-capabilities-refresh', + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + configId: capabilityConfigId, + }); + + // The catalog is written durably... + expect(updateAcpCapabilities).toHaveBeenCalledWith( + 'machine-1', + capabilityConfigId, + 'registry', + 'cursor', + [], + storedCapability.models, + undefined, + undefined, + false, + expect.any(String), + undefined, + false, + expect.objectContaining({ configOptionsByModel: catalog }) + ); + // ...but the response carries the entry without it, so a client whose strict + // capability schema predates the field still parses a successful refresh. + expect(result.success).toBe(true); + expect(result.capability).toBeDefined(); + expect(result.capability && 'configOptionsByModel' in result.capability).toBe(false); + const expectedWireCapability: Record = { ...storedCapability }; + delete expectedWireCapability.configOptionsByModel; + expect(result.capability).toEqual(expectedWireCapability); + // The stored entry itself is untouched. + expect(storedCapability.configOptionsByModel).toEqual(catalog); + }); + it('deduplicates concurrent ACP capability refreshes for the same config and launch inputs', async () => { let release: () => void = () => {}; const fetched = new Promise((resolve) => { From 7a78bac0e8f7f9c36de888db36cfd9eb363529ff Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 18:02:36 +0800 Subject: [PATCH 12/13] fix(shared): negotiate the Cursor picker marker through protocolCapabilities isAcpCapabilityCacheEntryCurrent rejected every registry Cursor row without the +parameterized-model-picker source-version marker, so a client newer than a remote machine's daemon treated that daemon's valid variants-mode rows as unavailable forever: the older daemon can never write the marker. The daemon now advertises the cursorParameterizedModelPicker protocol capability, and the current-ness predicates take the owning machine: the marker is required only on a machine that advertises the capability, while a legacy daemon's unmarked rows stay current for it. Every consumer passes the machine that owns the row. Refs #343 Model: claude-fable-5-1-thinking-max --- ...ssage-handler-machine-registration.test.ts | 1 + .../components/onboarding/provider-status.ts | 7 +- .../onboarding/screens/providers-screen.tsx | 16 ++- .../sessions/session-chat-interface.tsx | 6 +- .../components/sessions/session-detail.tsx | 12 +-- .../settings/agent-config-dialog.tsx | 5 +- .../components/shared/acp-selector-options.ts | 16 ++- .../src/hooks/use-available-commands.ts | 4 +- .../src/hooks/use-workspace-agent-roles.ts | 11 +- .../tests/acp-selector-options.test.ts | 2 +- .../components/tests/provider-status.test.ts | 20 ++-- .../use-agent-role-availability.test.tsx | 28 ++++- packages/shared/src/ai.ts | 40 +++++-- .../src/machine-protocol-capabilities.ts | 23 +++- .../shared/tests/acp-capability-cache.test.ts | 101 ++++++++++++++---- 15 files changed, 221 insertions(+), 71 deletions(-) diff --git a/apps/cli/tests/message-handler-machine-registration.test.ts b/apps/cli/tests/message-handler-machine-registration.test.ts index 6b86e6830..0eb4fe374 100644 --- a/apps/cli/tests/message-handler-machine-registration.test.ts +++ b/apps/cli/tests/message-handler-machine-registration.test.ts @@ -186,6 +186,7 @@ describe('MessageHandler machine registration', () => { localProjectRemoval: 1, providerSetup: 1, acpProtocolAuthentication: 2, + cursorParameterizedModelPicker: 1, }); await handler.cleanup(); diff --git a/packages/components/src/components/onboarding/provider-status.ts b/packages/components/src/components/onboarding/provider-status.ts index 798aa31ac..d419d71b0 100644 --- a/packages/components/src/components/onboarding/provider-status.ts +++ b/packages/components/src/components/onboarding/provider-status.ts @@ -14,12 +14,13 @@ type ProviderStatusInput = Pick< export function resolveInitialOnboardingProviderStatus( config: ProviderStatusInput, - acpCapabilities: MachineViewMeta['acpCapabilities'] | undefined + machine: Pick | undefined ): Extract { const cacheKey = getAcpCapabilityCacheKey(config.id); return getAcpCapabilityCacheEntryAuthority( - acpCapabilities?.[cacheKey], - config.runtimeOverrides + machine?.acpCapabilities?.[cacheKey], + config.runtimeOverrides, + machine ) === 'authoritative' ? 'passed' : 'untested'; diff --git a/packages/components/src/components/onboarding/screens/providers-screen.tsx b/packages/components/src/components/onboarding/screens/providers-screen.tsx index ea577f4d6..f2a99d5c4 100644 --- a/packages/components/src/components/onboarding/screens/providers-screen.tsx +++ b/packages/components/src/components/onboarding/screens/providers-screen.tsx @@ -683,24 +683,30 @@ export function ProvidersScreen({ // so they must never produce a Verified badge. Don't downgrade an explicit // 'failed' / 'passed'. A current activity is stored separately and must not // erase the last known result while a re-test is in flight. - // Depend on the cache map directly: `localMachine` identity rebuilds whenever - // the visible-machine index recomputes, which would re-fire this effect for - // unrelated reasons. + // Depend on the cache map and protocol set directly: `localMachine` identity + // rebuilds whenever the visible-machine index recomputes, which would re-fire + // this effect for unrelated reasons. const acpCapabilities = localMachine?.acpCapabilities; + const protocolCapabilities = localMachine?.protocolCapabilities; useEffect(() => { setTestStatuses((prev) => { let next = prev; for (const config of localConfigs) { const existing = prev[config.id]; if (existing === 'failed' || existing === 'passed') continue; - if (resolveInitialOnboardingProviderStatus(config, acpCapabilities) === 'passed') { + if ( + resolveInitialOnboardingProviderStatus(config, { + acpCapabilities, + protocolCapabilities, + }) === 'passed' + ) { if (next === prev) next = { ...prev }; next[config.id] = 'passed'; } } return next; }); - }, [localConfigs, acpCapabilities]); + }, [localConfigs, acpCapabilities, protocolCapabilities]); // If the local machine never arrives, silently restart the CLI once and // give it another window to reconnect. If it still doesn't show up, surface diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 5e00bee7f..3f181b58a 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -2903,8 +2903,8 @@ export const SessionChatInterface = memo( if (entry.finished !== true || !entry.acpTurnId || !session.agentConfigId) return null; const capability = sessionMachine?.acpCapabilities?.[getAcpCapabilityCacheKey(session.agentConfigId)]; - return getAcpCapabilityCacheEntryAuthority(capability, undefined) === 'authoritative' && - capability?.sessionFork === true + return getAcpCapabilityCacheEntryAuthority(capability, undefined, sessionMachine) === + 'authoritative' && capability?.sessionFork === true ? userMessage.id : null; } @@ -2919,7 +2919,7 @@ export const SessionChatInterface = memo( session.cliType, session.isArchived, sessionHistory, - sessionMachine?.acpCapabilities, + sessionMachine, ]); const handleEditLastUser = useCallback( async (message: SessionHistoryParsed, text: string): Promise => { diff --git a/packages/components/src/components/sessions/session-detail.tsx b/packages/components/src/components/sessions/session-detail.tsx index b39b4ac2b..77eaef69f 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -1194,11 +1194,11 @@ const SessionDetail = ({ const capability = sessionMachine?.acpCapabilities?.[getAcpCapabilityCacheKey(target.agentConfigId)]; return ( - getAcpCapabilityCacheEntryAuthority(capability, undefined) === 'authoritative' && - capability?.sessionFork === true + getAcpCapabilityCacheEntryAuthority(capability, undefined, sessionMachine) === + 'authoritative' && capability?.sessionFork === true ); }, - [sessionMachine?.acpCapabilities] + [sessionMachine] ); const canForkSessionToWorktree = useCallback( (target: SessionMeta): boolean => { @@ -1212,11 +1212,11 @@ const SessionDetail = ({ const capability = sessionMachine?.acpCapabilities?.[getAcpCapabilityCacheKey(target.agentConfigId)]; return ( - getAcpCapabilityCacheEntryAuthority(capability, undefined) === 'authoritative' && - capability?.sessionForkWorktree === true + getAcpCapabilityCacheEntryAuthority(capability, undefined, sessionMachine) === + 'authoritative' && capability?.sessionForkWorktree === true ); }, - [sessionMachine?.acpCapabilities] + [sessionMachine] ); const handleForkAssistant = useCallback( async ( diff --git a/packages/components/src/components/settings/agent-config-dialog.tsx b/packages/components/src/components/settings/agent-config-dialog.tsx index 44f95733f..bc43e3e97 100644 --- a/packages/components/src/components/settings/agent-config-dialog.tsx +++ b/packages/components/src/components/settings/agent-config-dialog.tsx @@ -879,7 +879,7 @@ function resolveInitialTestedCustomKey( const config = mode.config; if (config.cliType !== 'custom' || !config.customAcp) return null; const entry = machine.acpCapabilities?.[getAcpCapabilityCacheKey(config.id)]; - if (!isAcpCapabilityCacheEntryCurrent(entry)) return null; + if (!isAcpCapabilityCacheEntryCurrent(entry, machine)) return null; if (entry.sourceVersion !== `custom:${serializeCustomAcpLaunchSpec(config.customAcp)}`) { return null; } @@ -1092,7 +1092,8 @@ export function AgentConfigDialog(props: AgentConfigDialogProps) { const cacheKey = getAcpCapabilityCacheKey(agentConfigId); const cachedCapabilityAuthority = getAcpCapabilityCacheEntryAuthority( machine.acpCapabilities?.[cacheKey], - formData.runtimeOverrides + formData.runtimeOverrides, + machine ); const hasCachedCaps = formData.cliType === 'builtin' && formData.agentType === 'kimi' diff --git a/packages/components/src/components/shared/acp-selector-options.ts b/packages/components/src/components/shared/acp-selector-options.ts index 1d816dc0a..70e22824c 100644 --- a/packages/components/src/components/shared/acp-selector-options.ts +++ b/packages/components/src/components/shared/acp-selector-options.ts @@ -201,7 +201,7 @@ export type AcpSelectorTarget = { selectedModelId?: string | null; configOptionValues?: Record; runtimeOverrides?: BuiltinRuntimeOverrides; - machine?: Pick | null; + machine?: Pick | null; }; /** @@ -223,8 +223,18 @@ const resolveConfigOptions = (target?: AcpSelectorTarget): ResolvedConfigOptions if (target.configId) { const key = getAcpCapabilityCacheKey(target.configId); const capability = target.machine?.acpCapabilities?.[key]; - if (isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(capability, target.runtimeOverrides)) { - const authority = getAcpCapabilityCacheEntryAuthority(capability, target.runtimeOverrides); + if ( + isAcpCapabilityCacheEntryCurrentForRuntimeOverrides( + capability, + target.runtimeOverrides, + target.machine + ) + ) { + const authority = getAcpCapabilityCacheEntryAuthority( + capability, + target.runtimeOverrides, + target.machine + ); const modelReasoningEfforts = capability.modelReasoningEfforts; if (capability.configOptions?.length) { return { authority, configOptions: capability.configOptions, modelReasoningEfforts }; diff --git a/packages/components/src/hooks/use-available-commands.ts b/packages/components/src/hooks/use-available-commands.ts index f1cb7615e..650b00416 100644 --- a/packages/components/src/hooks/use-available-commands.ts +++ b/packages/components/src/hooks/use-available-commands.ts @@ -20,7 +20,9 @@ export function useAvailableCommands(target?: AcpSelectorTarget): AcpCommandSumm if (!configId || !cliType || !agentType) return []; const key = getAcpCapabilityCacheKey(configId); const capability = machine?.acpCapabilities?.[key]; - if (!isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(capability, runtimeOverrides)) { + if ( + !isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(capability, runtimeOverrides, machine) + ) { return []; } return capability.availableCommands ?? []; diff --git a/packages/components/src/hooks/use-workspace-agent-roles.ts b/packages/components/src/hooks/use-workspace-agent-roles.ts index 4e1100ea6..86640e0e4 100644 --- a/packages/components/src/hooks/use-workspace-agent-roles.ts +++ b/packages/components/src/hooks/use-workspace-agent-roles.ts @@ -107,11 +107,14 @@ export function useAgentRoleAvailability( for (const config of agentConfigs) { if (!config.machineId) continue; agentConfigMachineIds.set(config.id, config.machineId); - const capability = machines.get(config.machineId)?.acpCapabilities?.[ - getAcpCapabilityCacheKey(config.id) - ]; + const machine = machines.get(config.machineId); + const capability = machine?.acpCapabilities?.[getAcpCapabilityCacheKey(config.id)]; if ( - isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(capability, config.runtimeOverrides) + isAcpCapabilityCacheEntryCurrentForRuntimeOverrides( + capability, + config.runtimeOverrides, + machine + ) ) { agentConfigCapabilities.set(config.id, capability); } diff --git a/packages/components/tests/acp-selector-options.test.ts b/packages/components/tests/acp-selector-options.test.ts index 8ee540eb9..64c809a80 100644 --- a/packages/components/tests/acp-selector-options.test.ts +++ b/packages/components/tests/acp-selector-options.test.ts @@ -17,7 +17,7 @@ import { const agentConfigId = 'config-1' as AgentConfigId; const machineWithCapabilities = (acpCapabilities: MachineViewMeta['acpCapabilities']) => - ({ acpCapabilities }) as Pick; + ({ acpCapabilities }) as Pick; const codexMachineWithConfigOptions = (configOptions: AcpConfigOptionSummary[]) => machineWithCapabilities({ diff --git a/packages/components/tests/provider-status.test.ts b/packages/components/tests/provider-status.test.ts index 969ae4045..b48754979 100644 --- a/packages/components/tests/provider-status.test.ts +++ b/packages/components/tests/provider-status.test.ts @@ -28,11 +28,11 @@ const runtimeEntry: AcpCapabilityCacheEntry = { fetchedAt: 1, }; -function capabilities( +function machineWithCapabilities( entry: AcpCapabilityCacheEntry, targetConfig: typeof config = config -): MachineViewMeta['acpCapabilities'] { - return { [getAcpCapabilityCacheKey(targetConfig.id)]: entry }; +): Pick { + return { acpCapabilities: { [getAcpCapabilityCacheKey(targetConfig.id)]: entry } }; } describe('resolveInitialOnboardingProviderStatus', () => { @@ -41,19 +41,19 @@ describe('resolveInitialOnboardingProviderStatus', () => { expect( resolveInitialOnboardingProviderStatus( config, - capabilities({ ...runtimeEntry, provenance: undefined }) + machineWithCapabilities({ ...runtimeEntry, provenance: undefined }) ) ).toBe('untested'); }); it('only treats a current runtime probe as verified', () => { - expect(resolveInitialOnboardingProviderStatus(config, capabilities(runtimeEntry))).toBe( - 'passed' - ); + expect( + resolveInitialOnboardingProviderStatus(config, machineWithCapabilities(runtimeEntry)) + ).toBe('passed'); expect( resolveInitialOnboardingProviderStatus( config, - capabilities({ ...runtimeEntry, cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1 }) + machineWithCapabilities({ ...runtimeEntry, cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1 }) ) ).toBe('untested'); }); @@ -73,13 +73,13 @@ describe('resolveInitialOnboardingProviderStatus', () => { expect( resolveInitialOnboardingProviderStatus( claudeConfig, - capabilities({ ...claudeRuntimeEntry, provenance: undefined }, claudeConfig) + machineWithCapabilities({ ...claudeRuntimeEntry, provenance: undefined }, claudeConfig) ) ).toBe('untested'); expect( resolveInitialOnboardingProviderStatus( claudeConfig, - capabilities(claudeRuntimeEntry, claudeConfig) + machineWithCapabilities(claudeRuntimeEntry, claudeConfig) ) ).toBe('passed'); }); diff --git a/packages/components/tests/use-agent-role-availability.test.tsx b/packages/components/tests/use-agent-role-availability.test.tsx index bf7fabbc0..fbeb4ed0e 100644 --- a/packages/components/tests/use-agent-role-availability.test.tsx +++ b/packages/components/tests/use-agent-role-availability.test.tsx @@ -6,7 +6,9 @@ import { Provider, createStore, type Store } from 'jotai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ACP_CAPABILITY_CACHE_VERSION, + CURSOR_PARAMETERIZED_MODEL_PICKER_PROTOCOL_VERSION, CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX, + MACHINE_PROTOCOL_CAPABILITIES, getAcpCapabilityCacheKey, getLodyMachinePresenceKey, machineFlockKeys, @@ -117,7 +119,13 @@ describe('useAgentRoleAvailability', () => { return null; } - function publishCapability(entry?: AcpCapabilityCacheEntry) { + function publishCapability( + entry?: AcpCapabilityCacheEntry, + protocolCapabilities: MachineViewMeta['protocolCapabilities'] | null = { + [MACHINE_PROTOCOL_CAPABILITIES.cursorParameterizedModelPicker]: + CURSOR_PARAMETERIZED_MODEL_PICKER_PROTOCOL_VERSION, + } + ) { visibleMachines.machines = new Map([ [ machineId, @@ -128,6 +136,7 @@ describe('useAgentRoleAvailability', () => { os: 'linux', sessions: [], raceLimits: {}, + ...(protocolCapabilities === null ? {} : { protocolCapabilities }), acpCapabilities: entry ? { [getAcpCapabilityCacheKey(configId)]: entry } : {}, }, ], @@ -223,4 +232,21 @@ describe('useAgentRoleAvailability', () => { mentionableIds: [savedRole.id], }); }); + + it('treats an unmarked registry Cursor row as current only on a legacy daemon', async () => { + const unmarked = { ...capability(), sourceVersion: 'synthetic-cursor' }; + const savedRole = role({ modelId: currentModelId }); + await publishAgentConfig(); + + publishCapability(unmarked, null); + await render(savedRole); + expect(snapshot).toEqual({ + availability: { kind: 'available' }, + mentionableIds: [savedRole.id], + }); + + publishCapability(unmarked); + await render(savedRole); + expect(snapshot).toEqual({ availability: { kind: 'unknown' }, mentionableIds: [] }); + }); }); diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 50fec1f3d..de2c05b3e 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -11,6 +11,10 @@ import type { AgentConfigId, AgentRoleId, McpServerId, SessionId } from './ids'; import type { MessageTextSpan } from './message-text-spans'; import type { MinimalVisualAnnotationAnchor } from './visual-annotation-types'; import type { WorktreeScriptPhase } from './project'; +import { + machineSupportsCursorParameterizedModelPicker, + type MachineProtocolCapabilityCarrier, +} from './machine-protocol-capabilities'; import { DEEPSEEK_HARNESS_AGENT_PRESETS, DEEPSEEK_HARNESS_PERMISSION_MODES, @@ -340,22 +344,33 @@ export const isRegistryCursorAgent = (identity: { }): 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. + * Appended to registry Cursor's capability source version once the daemon declares + * `parameterizedModelPicker`. On a machine that advertises the + * `cursorParameterizedModelPicker` protocol capability, a registry Cursor row without + * the marker was probed before the opt-in: it describes exploded variant model ids the + * agent no longer advertises and carries no per-model catalog, so it is never current. + * A machine without that capability still launches Cursor in legacy variants mode, and + * its unmarked rows are the correct description of what it runs. */ export const CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX = '+parameterized-model-picker'; +/** + * The daemon that owns the capability row, as a `protocolCapabilities` carrier + * (`MachineMeta` / `MachineViewMeta`). Missing capabilities mean legacy. + */ +export type AcpCapabilityMachine = MachineProtocolCapabilityCarrier | null | undefined; + export const isAcpCapabilityCacheEntryCurrent = ( - entry: AcpCapabilityCacheEntry | undefined + entry: AcpCapabilityCacheEntry | undefined, + machine: AcpCapabilityMachine ): entry is AcpCapabilityCacheEntry => { if (entry?.cacheVersion !== ACP_CAPABILITY_CACHE_VERSION) { return false; } if ( isRegistryCursorAgent(entry) && + machineSupportsCursorParameterizedModelPicker(machine) && entry.sourceVersion?.endsWith(CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX) !== true ) { return false; @@ -365,9 +380,10 @@ export const isAcpCapabilityCacheEntryCurrent = ( export const isAcpCapabilityCacheEntryCurrentForRuntimeOverrides = ( entry: AcpCapabilityCacheEntry | undefined, - runtimeOverrides: BuiltinRuntimeOverrides | undefined + runtimeOverrides: BuiltinRuntimeOverrides | undefined, + machine: AcpCapabilityMachine ): entry is AcpCapabilityCacheEntry => { - if (!isAcpCapabilityCacheEntryCurrent(entry)) { + if (!isAcpCapabilityCacheEntryCurrent(entry, machine)) { return false; } const sourceVersionSuffix = getBuiltinRuntimeOverrideSourceVersionSuffix(runtimeOverrides); @@ -376,9 +392,10 @@ export const isAcpCapabilityCacheEntryCurrentForRuntimeOverrides = ( export const getAcpCapabilityCacheEntryAuthority = ( entry: AcpCapabilityCacheEntry | undefined, - runtimeOverrides: BuiltinRuntimeOverrides | undefined + runtimeOverrides: BuiltinRuntimeOverrides | undefined, + machine: AcpCapabilityMachine ): AcpCapabilityAuthority => { - if (!isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(entry, runtimeOverrides)) { + if (!isAcpCapabilityCacheEntryCurrentForRuntimeOverrides(entry, runtimeOverrides, machine)) { return 'unavailable'; } return entry.provenance === 'runtime' ? 'authoritative' : 'provisional'; @@ -391,12 +408,13 @@ export type AcpCapabilityCacheStaleReason = export const getAcpCapabilityCacheStaleReason = ( entry: AcpCapabilityCacheEntry | undefined, - expectedSourceVersion: string + expectedSourceVersion: string, + machine: AcpCapabilityMachine ): AcpCapabilityCacheStaleReason | undefined => { if (!entry) { return 'missing'; } - if (!isAcpCapabilityCacheEntryCurrent(entry)) { + if (!isAcpCapabilityCacheEntryCurrent(entry, machine)) { return 'cache-version-mismatch'; } if (entry.sourceVersion !== expectedSourceVersion) { diff --git a/packages/shared/src/machine-protocol-capabilities.ts b/packages/shared/src/machine-protocol-capabilities.ts index 288d45ab6..60dc3e2c3 100644 --- a/packages/shared/src/machine-protocol-capabilities.ts +++ b/packages/shared/src/machine-protocol-capabilities.ts @@ -12,14 +12,16 @@ export const MACHINE_PROTOCOL_CAPABILITIES = { localProjectRemoval: 'localProjectRemoval', providerSetup: 'providerSetup', acpProtocolAuthentication: 'acpProtocolAuthentication', + cursorParameterizedModelPicker: 'cursorParameterizedModelPicker', } as const; export const ACP_AUTHENTICATION_INTERACTIONS_PROTOCOL_VERSION = 2; export const LOCAL_PROJECT_REMOVAL_PROTOCOL_VERSION = 1; export const PROVIDER_SETUP_PROTOCOL_VERSION = 1; export const ACP_PROTOCOL_AUTHENTICATION_VERSION = 2; +export const CURSOR_PARAMETERIZED_MODEL_PICKER_PROTOCOL_VERSION = 1; -type MachineProtocolCapabilityCarrier = { +export type MachineProtocolCapabilityCarrier = { protocolCapabilities?: MachineProtocolCapabilities; }; @@ -52,6 +54,8 @@ export const CURRENT_MACHINE_PROTOCOL_CAPABILITIES: MachineProtocolCapabilities [MACHINE_PROTOCOL_CAPABILITIES.localProjectRemoval]: LOCAL_PROJECT_REMOVAL_PROTOCOL_VERSION, [MACHINE_PROTOCOL_CAPABILITIES.providerSetup]: PROVIDER_SETUP_PROTOCOL_VERSION, [MACHINE_PROTOCOL_CAPABILITIES.acpProtocolAuthentication]: ACP_PROTOCOL_AUTHENTICATION_VERSION, + [MACHINE_PROTOCOL_CAPABILITIES.cursorParameterizedModelPicker]: + CURSOR_PARAMETERIZED_MODEL_PICKER_PROTOCOL_VERSION, }; /** Whether the target daemon supports interactive Custom/Registry ACP authentication. */ @@ -102,3 +106,20 @@ export function machineSupportsAcpProtocolAuthentication( ACP_PROTOCOL_AUTHENTICATION_VERSION ); } + +/** + * Whether the target daemon launches registry Cursor with + * `clientCapabilities._meta.parameterizedModelPicker`. Its capability rows then carry + * the `+parameterized-model-picker` source-version marker; a daemon without this + * capability still runs Cursor in legacy variants mode, and its unmarked rows are the + * correct description of what it launches. + */ +export function machineSupportsCursorParameterizedModelPicker( + machine: MachineProtocolCapabilityCarrier | null | undefined +): boolean { + return machineSupportsProtocolCapability( + machine, + MACHINE_PROTOCOL_CAPABILITIES.cursorParameterizedModelPicker, + CURSOR_PARAMETERIZED_MODEL_PICKER_PROTOCOL_VERSION + ); +} diff --git a/packages/shared/tests/acp-capability-cache.test.ts b/packages/shared/tests/acp-capability-cache.test.ts index 2f0d75b69..8ec7e5b84 100644 --- a/packages/shared/tests/acp-capability-cache.test.ts +++ b/packages/shared/tests/acp-capability-cache.test.ts @@ -4,10 +4,17 @@ import { ACP_CAPABILITY_CACHE_VERSION, CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX, getAcpCapabilityCacheEntryAuthority, + getAcpCapabilityCacheStaleReason, isAcpCapabilityCacheEntryCurrent, isRegistryCursorAgent, type AcpCapabilityCacheEntry, } from '../src/ai'; +import { + CURRENT_MACHINE_PROTOCOL_CAPABILITIES, + CURSOR_PARAMETERIZED_MODEL_PICKER_PROTOCOL_VERSION, + MACHINE_PROTOCOL_CAPABILITIES, + machineSupportsCursorParameterizedModelPicker, +} from '../src/machine-protocol-capabilities'; const cacheEntry = ( fields: Pick & @@ -21,6 +28,26 @@ const cacheEntry = ( ...fields, }); +/** A daemon that launches registry Cursor with the parameterized model picker. */ +const pickerMachine = { protocolCapabilities: CURRENT_MACHINE_PROTOCOL_CAPABILITIES }; +/** A daemon from before the opt-in: it still runs Cursor in variants mode. */ +const legacyMachine = { + protocolCapabilities: { [MACHINE_PROTOCOL_CAPABILITIES.providerSetup]: 1 }, +}; + +const unmarkedCursorRow = () => + cacheEntry({ + cliType: 'registry', + agentType: 'cursor', + sourceVersion: 'cursor@2026.08.31', + }); +const markedCursorRow = () => + cacheEntry({ + cliType: 'registry', + agentType: 'cursor', + sourceVersion: `cursor@2026.08.31${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}`, + }); + describe('isRegistryCursorAgent', () => { it('is true only for registry Cursor identity', () => { expect(isRegistryCursorAgent({ cliType: 'registry', agentType: 'cursor' })).toBe(true); @@ -30,34 +57,67 @@ describe('isRegistryCursorAgent', () => { }); }); +describe('cursorParameterizedModelPicker protocol capability', () => { + it('shares one version binding between advertisement and negotiation', () => { + expect( + CURRENT_MACHINE_PROTOCOL_CAPABILITIES[ + MACHINE_PROTOCOL_CAPABILITIES.cursorParameterizedModelPicker + ] + ).toBe(CURSOR_PARAMETERIZED_MODEL_PICKER_PROTOCOL_VERSION); + expect(machineSupportsCursorParameterizedModelPicker(pickerMachine)).toBe(true); + }); + + it('treats a missing capability as a legacy daemon', () => { + expect(machineSupportsCursorParameterizedModelPicker(legacyMachine)).toBe(false); + expect(machineSupportsCursorParameterizedModelPicker(undefined)).toBe(false); + expect(machineSupportsCursorParameterizedModelPicker(null)).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 pre-opt-in registry Cursor row on a machine that launches the picker', () => { + const entry = unmarkedCursorRow(); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).toBe(false); + expect(getAcpCapabilityCacheEntryAuthority(entry, undefined, pickerMachine)).toBe( + 'unavailable' + ); + expect( + getAcpCapabilityCacheStaleReason( + entry, + `cursor@2026.08.31${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}`, + pickerMachine + ) + ).toBe('cache-version-mismatch'); }); - it('rejects a registry Cursor row with no sourceVersion', () => { + it('rejects a registry Cursor row with no sourceVersion on a picker machine', () => { const entry = cacheEntry({ cliType: 'registry', agentType: 'cursor', sourceVersion: undefined, }); - expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(false); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).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'); + const entry = markedCursorRow(); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).toBe(true); + expect(getAcpCapabilityCacheEntryAuthority(entry, undefined, pickerMachine)).toBe( + 'authoritative' + ); + }); + + it('accepts an unmarked registry Cursor row from a daemon without the picker capability', () => { + // That daemon still launches Cursor in variants mode, so its row describes what it runs. + const entry = unmarkedCursorRow(); + expect(isAcpCapabilityCacheEntryCurrent(entry, legacyMachine)).toBe(true); + expect(isAcpCapabilityCacheEntryCurrent(entry, undefined)).toBe(true); + expect(getAcpCapabilityCacheEntryAuthority(entry, undefined, legacyMachine)).toBe( + 'authoritative' + ); + expect(getAcpCapabilityCacheStaleReason(entry, 'cursor@2026.08.31', legacyMachine)).toBe( + undefined + ); }); it('does not require the marker for a registry non-Cursor agent', () => { @@ -66,7 +126,7 @@ describe('isAcpCapabilityCacheEntryCurrent', () => { agentType: 'gemini', sourceVersion: 'gemini@1.0.0', }); - expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(true); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).toBe(true); }); it('does not require the marker for a custom Cursor agent', () => { @@ -75,7 +135,7 @@ describe('isAcpCapabilityCacheEntryCurrent', () => { agentType: 'cursor', sourceVersion: 'custom:{"command":"cursor-agent"}', }); - expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(true); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).toBe(true); }); it('still rejects a marked registry Cursor row with a stale cache version', () => { @@ -85,6 +145,7 @@ describe('isAcpCapabilityCacheEntryCurrent', () => { cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1, sourceVersion: `cursor@2026.08.31${CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX}`, }); - expect(isAcpCapabilityCacheEntryCurrent(entry)).toBe(false); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).toBe(false); + expect(isAcpCapabilityCacheEntryCurrent(entry, legacyMachine)).toBe(false); }); }); From ed5eeff6b31ead95ad7fb5862d6a9c79312bc466 Mon Sep 17 00:00:00 2001 From: terry Date: Sun, 6 Sep 2026 18:04:58 +0800 Subject: [PATCH 13/13] docs(cli): record the Cursor catalog contracts in the condensed AGENTS.md The rebase onto main took the condensed apps/cli AGENTS.md files from #431, which dropped this branch's Cursor paragraphs. Restate the invariants as compact bullets (registry identity gate, marker and protocol capability, catalog observation and write contract, refresh response shape, per-model validation) and move the mechanics into the agent README that main now uses for background. Refs #343 Model: claude-fable-5-1-thinking-max --- apps/cli/AGENTS.md | 7 ++++++- apps/cli/src/agent/AGENTS.md | 15 +++++++++++++++ apps/cli/src/agent/README.md | 23 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 06e7a0e63..a93620c58 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -57,7 +57,12 @@ Root `AGENTS.md` applies; this file adds CLI context. Build, PR-poller, and adap - INVARIANT: reasoning effort and fast mode are per MODEL, because an ACP probe's `configOptions` describe only the model current at probe time. Validate effort against the TARGET model using `AcpCapabilityCacheEntry.modelReasoningEfforts` and skip the resulting `validatedConfigIds` in - `validateTurnConfigOptionValues`; dispatch what cannot be checked offline as requested. Keep + `validateTurnConfigOptionValues`. When the cache carries `configOptionsByModel` (registry + Cursor), mapping, turn validation, and inherited-default filtering read the TARGET model's + composed options through `resolveAcpConfigOptionsForModel`; inherited defaults are filtered + against the MERGED target model, and an explicit create `modelId` drops a parent's superseded + `model` option so the frozen Turn names one model. Dispatch what cannot be checked offline + as requested. Keep runtime rejections in debug diagnostics: Codex/Claude mismatches for model, effort, Fast, or Plan never become visible `agent_warning` notices, while other rejections still do. Claude Fable models omit Fast, so `fast=false` is skipped as a no-op while `fast=true` is dispatched. diff --git a/apps/cli/src/agent/AGENTS.md b/apps/cli/src/agent/AGENTS.md index ad3352b93..d4172287f 100644 --- a/apps/cli/src/agent/AGENTS.md +++ b/apps/cli/src/agent/AGENTS.md @@ -13,6 +13,13 @@ context/acp-agent-edit-evidence.md; adapter repos: [apps/cli/AGENTS.md](../../AG compatibility adapter, never in session consumers; normalized Core capabilities stay provider-neutral. - Builtin Grok must default `clientCapabilities.terminal` to false. +- Registry Cursor identity (`cliType: 'registry'` and `agentType: 'cursor'`, never a same-named + custom or builtin config) gates the `parameterizedModelPicker` opt-in, the + `CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX` marker, and the + `cursorParameterizedModelPicker` protocol capability. `isAcpCapabilityCacheEntryCurrent` + rejects an unmarked registry Cursor row only on a machine that advertises that capability; a + legacy daemon's unmarked rows stay current. Predicate, suffix, and capability are one binding + in `@lody/shared`; never re-derive them in the CLI. - Send the driving turn's config on every session establishment as `_meta.lody.sessionConfig`; provider-specific startup translation belongs in the ACP adapter. `session/set_config_option` stays the live-session switch, and a successful selection becomes a later replacement's @@ -107,6 +114,14 @@ context/acp-agent-edit-evidence.md; adapter repos: [apps/cli/AGENTS.md](../../AG the cache, and requests/responses carry that id to keep configs of one provider isolated. `ManagedRuntimeUpdateCoordinator` never hot-swaps a running ACP process, and Machine Flock writes ignore `fetchedAt` when comparing entries. +- Registry Cursor's per-model catalog (`configOptionsByModel`, background in + [README.md](README.md)) is the latest successful `cursor/list_available_models` observation + from the explicit probe or a created session, never enumerated through + `session/set_config_option` (it rewrites the user's global Cursor config). A confirmed + `-32601` clears it and any other failure keeps the stored catalog; the write contract lives + in `../lib/loro/AGENTS.md`. `machine/acp-capabilities-refresh_response.capability` omits the + catalog: clients parse it through a strict schema, and the Flock row reader tolerates unknown + fields. `resolveAcpConfigOptionsForModel` in `@lody/shared` is the one composition rule. - Builtin Claude owns session titles through ACP `session_info_update`; store them only after `sanitizeLodyInternalInstructions`, and never start `title-generator.ts`'s isolated session for Claude. For Codex accept only `explicit` `_meta.lody.titleSource` names, ignore its diff --git a/apps/cli/src/agent/README.md b/apps/cli/src/agent/README.md index 80f12eb17..d03c68205 100644 --- a/apps/cli/src/agent/README.md +++ b/apps/cli/src/agent/README.md @@ -164,6 +164,29 @@ per-model reasoning-effort ladders on that session response as Codex only — other agents use the same brackets for unrelated variants (Claude's `opus[1m]` is a context window). Vendor model `_meta` never enters the CLI. +### Registry Cursor per-model catalog + +Registry Cursor (`cliType: 'registry'` and `agentType: 'cursor'`) declares +`clientCapabilities._meta.parameterizedModelPicker` at initialize (`agent-client.ts`), so +cursor-agent advertises clean model ids and rebuilds thinking/effort/context/fast per model. +Because a `session/new` snapshot describes only the model current at that moment, +`cursor-acp.ts` fetches every model's options through the side-effect-free +`cursor/list_available_models` ext method once after `session/new` and stores them as +`AcpCapabilityCacheEntry.configOptionsByModel` (`[]` = a known model without options; a +missing key = unknown model). Both the explicit `machine/acp-capabilities-refresh` probe and +every created real session (inside the non-blocking cache update, on the live connection) +make that observation. JSON-RPC `-32601` means the agent publishes no catalog and travels as +`null` to the write, which clears a stored one; a validation failure, timeout, or abort fails +the probe with `[ACP_CAPABILITIES_INCOMPLETE]` so the Settings Test action can retry, while a +session logs it and omits the field so the stored catalog is inherited (write contract in +`../lib/loro/AGENTS.md`). `resolveAcpConfigOptionsForModel` in `@lody/shared` composes the +snapshot with the selected model's entry. The opt-in also changes the advertised model ids, +so `getAcpCapabilitySourceVersion` appends `CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX` +and the daemon advertises the `cursorParameterizedModelPicker` protocol capability; a +client requires the suffix only on a machine that advertises the capability, because a +daemon without it still runs Cursor in variants mode and its unmarked rows describe what it +launches. + ### Session titles Builtin Claude owns session title generation through ACP `session_info_update`. Builtin Codex