diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-acp-capability-cache-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-08-acp-capability-cache-compatibility.md index 677ad6df1..be29761f0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-08-acp-capability-cache-compatibility.md +++ b/.agents/notes/implemented/bug-fix/2026-09-08-acp-capability-cache-compatibility.md @@ -31,3 +31,30 @@ the stored config-scoped row carried cache version 6 while the renderer expected coverage separates readability from freshness and exercises missing, older, and newer version markers. This does not make unknown future fields readable: entries must still pass the client's wire or storage schema. See the [draft compatibility contract](../../../../specs/acp-capability-cache-compatibility.md). + +## Cursor picker integration + +PR [#344](https://github.com/LodyAI/Lody/pull/344) adds a protocol-specific exception: an +upgraded registry Cursor daemon launches clean model IDs, so its pre-picker rows are incompatible. +The shared readable entry helper takes the owning Machine and rejects those rows, while retaining +marked rows across cache versions. A legacy daemon still accepts its unmarked rows. Selectors, +commands, and CLI dispatch share this rule. CLI refreshes the incompatible row +and waits for the complete Flock publication before accepting its catalog. + +Renderer refresh catch-up uses that same owning-Machine boundary. A successfully read legacy +Machine without the picker capability may immediately overlay Cursor's complete RPC response when +its Flock replica is still behind. A picker-aware Machine keeps the complete Flock row instead, +because the compatibility RPC response omits the per-model catalog; missing or unreadable Machine +metadata also preserves that row without turning an already successful refresh into an error. + +Keeping exact cache-version checks in those readers would restore the original version-skew +failure; accepting all Cursor rows would instead dispatch legacy variant IDs to the new picker. +Regression tests cover both boundaries with synthetic cache rows, stale replicas, metadata failure, +and explicit publication signals. +These tests do not establish compatibility with a live Cursor service or Windows desktop runtime. + +## Contribution scope + +General Role availability and restoration are separated from the Cursor contribution. +See the [ablation decision](../../proposed/simplification/2026-09-10-cursor-catalog-contribution-scope.md) +for evidence, retained behavior, and the guarantees left to the separate Role patch. diff --git a/.agents/notes/proposed/simplification/2026-09-10-cursor-catalog-contribution-scope.md b/.agents/notes/proposed/simplification/2026-09-10-cursor-catalog-contribution-scope.md new file mode 100644 index 000000000..221dc37a7 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-10-cursor-catalog-contribution-scope.md @@ -0,0 +1,57 @@ +# Narrow the Cursor catalog contribution through ablation + +Status: proposed +Translation: pending + +## Abstract + +The Cursor catalog contribution also introduced general Role availability and restoration +behavior, with a separate capability-source publication protocol. Removing that group leaves +the selected-model picker and CLI/MCP tests passing, while its own behavioral tests fail as +expected. The proposed contribution therefore keeps the Cursor path and separates the Role +work for independent review. This is a scope reduction, not evidence that Role freshness and +restoration safeguards are unnecessary. + +## Scope and evidence + +This follows [Issue #343's maintainer feedback](https://github.com/LodyAI/Lody/issues/343#issuecomment-5611912115). +The experiments use PR #345 at `1754cd3a`, including PR #344 at `4349818d`, against +their recorded base `be639221`. Each mutation starts from the same baseline, and tests +remain unchanged until the behavioral comparison is recorded. + +| Removed behavior | Baseline | After removal | Decision | +| -------------------------------------------------- | ---------- | -------------------- | -------- | +| Target-model option composition | 33 passed | 25 passed, 8 failed | Keep | +| Model switch before per-model options | 9 passed | 8 passed, 1 failed | Keep | +| Catalog inheritance when a write omits it | 13 passed | 11 passed, 2 failed | Keep | +| Picker source-marker compatibility check | 10 passed | 8 passed, 2 failed | Keep | +| General Role availability/source/restoration group | 439 passed | 437 passed, 2 failed | Separate | + +The last two failures are the Role row's unsupported-model/mode explanations. Separate +Role availability controls change from 25 to 17 passing shared tests and from 13 to 2 +passing hook tests; these are observable losses of the separated feature. Tests of removed +APIs are not counted as behavioral failures. The final scoped suite retains 437 tests. + +## Responsibilities and trade-offs + +Keep registry-only picker opt-in, live/explicit catalog discovery, tri-state catalog writes, +mixed-daemon picker negotiation, refresh publication, and target-model CLI/MCP dispatch. +Keep Role parameter editing and model-change pruning, including Task/reviewer consumers; +removing them would leave parameters that the newly selected model cannot accept. + +Separate general Role model/mode availability checks, exact capability-source/epoch +publication, runtime-install listeners, and deferred landing restoration. These paths follow upstream behavior; the integration preserves subsequent upstream +Role mention discovery and availability changes. Existing machine/config binding, authorization, frozen MCP +Role dispatch, and permission handling remain. Preemptive rejection of a retired Role model +and capability-aware saved-Role restoration are not guarantees of this narrowed contribution. + +The experiments are deterministic source tests, not live Cursor, desktop, or Windows +acceptance. Both PRs remain Draft pending maintainer review. The independent +Role patch retains its original implementation and tests for later review. + +## Integration + +The reduced catalog branch integrates upstream `1ce45684`; its Plan-mode tests and +Cursor target-model tests are both retained. PR #345 builds on that reduced branch, +preserving the existing contribution ancestry. General Role files match upstream; +the separate patch is not part of either final PR diff. diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index c23152ce6..6c322ff74 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -3,7 +3,8 @@ `CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. Root `AGENTS.md` applies; this file adds CLI context. Build, PR-poller, and adapter background: [.agents/docs/cli-overview.md](../../.agents/docs/cli-overview.md). Scoped rules live under -`src/{agent,commands,session,mcp,orchestration,preview,lib}`. +`src/{agent,commands,session,mcp,orchestration,preview,lib}`. Before changing per-model run +config or Cursor capability discovery/storage, read [src/AGENTS.md](src/AGENTS.md). ## Build and packaging @@ -49,13 +50,6 @@ execution/consent rules. These rules also bind CLI callers outside that director - Child Sessions are one level deep. An independent Session created inside another persists exact provenance in `openedBySessionId`, plus `openedByRootSessionId` when the opener is a child Tab; never rewrite the exact opener to the root or treat either as `parentSessionId`. -- 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 - 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. - `lody feedback` and MCP `lody_feedback` submit only caller-provided suggestion text plus CLI version, platform, and architecture — never cwd, paths, hostname, environment, logs, prompts, history, or file contents. Keep obvious-secret rejection in the CLI and the hosted API boundary. diff --git a/apps/cli/src/AGENTS.md b/apps/cli/src/AGENTS.md new file mode 100644 index 000000000..42321d85f --- /dev/null +++ b/apps/cli/src/AGENTS.md @@ -0,0 +1,47 @@ +# CLI source contracts + +Parent `AGENTS.md` files apply. `CLAUDE.md` is a symlink; edit `AGENTS.md` only. +These contracts cross agent startup, capability storage, and CLI/MCP dispatch. + +## Per-model run config + +- 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`. 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. + +## Registry Cursor capability discovery + +- 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. +- Registry Cursor's per-model catalog (`configOptionsByModel`, background in + [agent/README.md](agent/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 is below. `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. + +## 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. diff --git a/apps/cli/src/CLAUDE.md b/apps/cli/src/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/apps/cli/src/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/apps/cli/src/agent/README.md b/apps/cli/src/agent/README.md index 522f47b15..cef54cfdb 100644 --- a/apps/cli/src/agent/README.md +++ b/apps/cli/src/agent/README.md @@ -184,6 +184,29 @@ to render understood fields from an older daemon's parsed entry while scheduling probe; likewise, an older client may use the understood portion of a newer entry. Runtime override entries still apply only when their source-version suffix matches the selected override. +### 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 +`../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 diff --git a/apps/cli/src/agent/acp-capabilities.test.ts b/apps/cli/src/agent/acp-capabilities.test.ts index a9fa90408..fd77ee816 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,56 @@ 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(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' + ); + 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..aa5f5f1dc 100644 --- a/apps/cli/src/agent/acp-capabilities.ts +++ b/apps/cli/src/agent/acp-capabilities.ts @@ -2,6 +2,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'; @@ -13,6 +14,8 @@ import { normalizeAcpSessionCapabilities, 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'; @@ -24,11 +27,21 @@ export type FetchAcpCapabilitiesOptions = { export type FetchedAcpCapabilities = AcpCapabilitiesResult & { capabilitySourceVersion?: string; + /** + * 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, @@ -88,13 +101,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 })) ?? null) + : 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 033cd7058..e4707b502 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -37,6 +37,7 @@ import { buildAskUserQuestionElicitationResponse, formatMcpResolutionProblem, getServerNow, + isRegistryCursorAgent, ACP_INIT_TIMEOUT_MS as DEFAULT_ACP_INIT_TIMEOUT_MS, ACP_NEW_SESSION_TIMEOUT_MS as DEFAULT_ACP_NEW_SESSION_TIMEOUT_MS, } from '@lody/shared'; @@ -217,7 +218,7 @@ function isAcpInvalidRequestError(error: unknown): boolean { ); } -function isAcpMethodNotFoundError(error: unknown): boolean { +export function isAcpMethodNotFoundError(error: unknown): boolean { return ( typeof error === 'object' && error !== null && @@ -1393,6 +1394,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); @@ -1765,6 +1800,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..454a42261 --- /dev/null +++ b/apps/cli/src/agent/cursor-acp.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CURSOR_LIST_AVAILABLE_MODELS_METHOD, fetchCursorModelCatalog } 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('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..893a1b46c --- /dev/null +++ b/apps/cli/src/agent/cursor-acp.ts @@ -0,0 +1,125 @@ +import type { SessionConfigOption } from '@agentclientprotocol/sdk'; +import type { AcpConfigOptionSummary } 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'; + +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/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/src/commands/AGENTS.md b/apps/cli/src/commands/AGENTS.md index 8d8238074..f4fd38bdd 100644 --- a/apps/cli/src/commands/AGENTS.md +++ b/apps/cli/src/commands/AGENTS.md @@ -94,12 +94,12 @@ Command entrypoints, the daemon runner, and session dispatch from the CLI/MCP bo already-running session out from under the daemon. Do not reintroduce a hard-fail Streams ack on the dispatch write. - MCP create takes run config semantically (`modelId`/`reasoningEffort`/`fastMode`/`planMode`), - never raw ACP option ids. `@lody/shared` `acp-run-config.ts` owns the mapping onto each agent's - advertised option ids, `applyAgentRunConfigSelection` applies it once the target agent's cached - capabilities are read, and `validateSessionCreateOptions({ dispatchConfig })` rejects - unsupported selections before the Operation is accepted. Durable create acceptance stores each - target's resolved effective dispatch config; recovery must use it instead of inheriting again - from mutable requester history. + never raw ACP ids. Resolve the inherited target model BEFORE mapping through shared + `acp-run-config.ts`. CLI/MCP use `readAgentAcpCapability`: version skew stays readable; incompatible Cursor + pre-picker rows trigger one probe and await the full Flock row. Machine protocol governs + legacy support. Reject unsupported + selections before Operation acceptance; freeze each target's effective dispatch config so + recovery never inherits again from mutable history. - Local daemon IPC sends the real control request once; do not restore a health preflight. Native `LocalDaemonAvailabilityError` must be thrown outside the Effect runtime boundary so MCP can preserve `DAEMON_NOT_RUNNING` versus retryable `DAEMON_BUSY`: a connection refusal means not diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index f2157418c..495e60328 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -4,9 +4,14 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { describe, expect, it, vi } from 'vitest'; import { + ACP_CAPABILITY_CACHE_VERSION, + CURRENT_MACHINE_PROTOCOL_CAPABILITIES, + machineFlockKeys, + type AgentConfigId, getMachineFlockDocId, getSessionRoomId, type AcpCapabilityCacheEntry, + type AcpConfigOptionSummary, type AgentConfigMeta, type LocalProjectGitState, type MachineId, @@ -16,6 +21,7 @@ import { type SessionMeta, type WorkspaceId, } from '@lody/shared'; +import * as commandRuntime from '@/lib/command-runtime'; import { createLocalProjectBranchSelector, normalizeLocalProjectRootPath, @@ -52,6 +58,8 @@ import { resolveRenameArgs, resolvePromptCandidate, resolveTurnDispatchConfigFromInputConfig, + resolveSessionCreateDispatchConfig, + readAgentAcpCapability, resolveLocalProjectBranchForCreate, resolveLocalProjectCreateGitContext, resolveLocalProjectRefOrThrow, @@ -116,6 +124,205 @@ const createMachineMeta = (overrides: Partial = {}): MachineMeta => ...overrides, }); +describe('readAgentAcpCapability', () => { + const setup = ( + options: { + legacy?: boolean; + missing?: boolean; + current?: boolean; + cacheVersion?: number; + agentType?: string; + } = {} + ) => { + const configId = 'cursor-config' as AgentConfigId; + const machine = createMachineMeta({ + protocolCapabilities: options.legacy ? undefined : CURRENT_MACHINE_PROTOCOL_CAPABILITIES, + }); + const legacyEntry: AcpCapabilityCacheEntry = { + cliType: 'registry', + agentType: options.agentType ?? 'cursor', + cacheVersion: options.cacheVersion ?? ACP_CAPABILITY_CACHE_VERSION, + sourceVersion: 'cursor@test', + modes: [], + models: [], + fetchedAt: 1, + }; + const freshEntry: AcpCapabilityCacheEntry = { + ...legacyEntry, + sourceVersion: 'cursor@test+parameterized-model-picker', + fetchedAt: 2, + configOptionsByModel: { model: [] }, + }; + let entry = options.missing ? undefined : options.current ? freshEntry : legacyEntry; + let observer: (() => void) | undefined; + let joined = false; + const manager = { + syncFlockDocOrThrow: async () => undefined, + repo: { + getDocMeta: async () => ({ meta: machine }), + openFlockDoc: async () => ({ + flock: { + subscribe: (callback: () => void) => { + observer = callback; + return () => { + observer = undefined; + }; + }, + scan: () => + entry ? [{ key: machineFlockKeys.acpCapability(configId), value: entry }] : [], + }, + joinRoom: async () => { + joined = true; + return { + unsubscribe: () => { + joined = false; + }, + }; + }, + }), + }, + } as unknown as Parameters[0]['manager']; + const args: Parameters[0] = { + auth: { + machineId: machine.id, + machineName: 'Test Machine', + token: 'synthetic-token', + userId: 'test-user', + userName: 'Test User', + userEmail: 'test@example.invalid', + }, + manager, + workspaceId: 'workspace-id' as WorkspaceId, + machineId: machine.id, + agentConfigId: configId, + agent: legacyEntry, + }; + const response = { + type: 'machine/acp-capabilities-refresh_response' as const, + machineId: machine.id, + configId, + cliType: 'registry' as const, + agentType: options.agentType ?? 'cursor', + success: true, + capability: { ...freshEntry, configOptionsByModel: undefined }, + }; + return { + args, + response, + legacyEntry, + freshEntry, + released: () => !joined && observer === undefined, + publish: () => { + entry = freshEntry; + observer?.(); + }, + }; + }; + + it.each([false, true])( + 'automatically refreshes stale or missing Cursor rows (missing=%s)', + async (missing) => { + const fixture = setup({ missing }); + const dispatch = vi + .spyOn(commandRuntime, 'dispatchLocalControl') + .mockImplementation(async (request) => { + expect(request).toMatchObject({ + type: 'machine/acp-capabilities-refresh', + configId: 'cursor-config', + }); + fixture.publish(); + return [fixture.response]; + }); + try { + await expect(readAgentAcpCapability(fixture.args)).resolves.toEqual(fixture.freshEntry); + expect(fixture.released()).toBe(true); + } finally { + dispatch.mockRestore(); + } + } + ); + + it.each([ + [{ legacy: true, cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1 }, false], + [{ current: true, cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1 }, true], + [ + { + agentType: 'synthetic-other-agent', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION - 1, + }, + false, + ], + [{ current: true }, true], + [{ legacy: true }, false], + ])('reads a compatible row without probing (%o)', async (options, expectsFresh) => { + const fixture = setup(options); + const dispatch = vi + .spyOn(commandRuntime, 'dispatchLocalControl') + .mockRejectedValue(new Error('unexpected probe')); + try { + await expect(readAgentAcpCapability(fixture.args)).resolves.toEqual( + expectsFresh ? fixture.freshEntry : fixture.legacyEntry + ); + } finally { + dispatch.mockRestore(); + } + }); + + it.each([false, true])( + 'rejects stale dispatch after an unsuccessful refresh (rpcFailed=%s)', + async (rpcFailed) => { + vi.useFakeTimers(); + const fixture = setup(); + const dispatch = vi.spyOn(commandRuntime, 'dispatchLocalControl').mockResolvedValue([ + { + ...fixture.response, + success: !rpcFailed, + ...(rpcFailed ? { error: 'synthetic probe failure' } : {}), + }, + ]); + try { + const rejection = expect(readAgentAcpCapability(fixture.args)).rejects.toThrow( + 'Automatic Cursor capability refresh' + ); + await vi.runAllTimersAsync(); + await rejection; + expect(fixture.released()).toBe(true); + } finally { + dispatch.mockRestore(); + vi.useRealTimers(); + } + } + ); + + it('waits for a full Flock row arriving after the refresh ACK', async () => { + const fixture = setup(); + const ack = Promise.withResolvers(); + const dispatch = vi + .spyOn(commandRuntime, 'dispatchLocalControl') + .mockImplementation(async () => { + ack.resolve(); + return [fixture.response]; + }); + try { + let settled = false; + const reading = readAgentAcpCapability(fixture.args).then((entry) => { + settled = true; + return entry; + }); + await ack.promise; + // Let the reader consume the ACK and observe the still-stale snapshot. + await Promise.resolve(); + expect(settled).toBe(false); + expect(fixture.released()).toBe(false); + fixture.publish(); + await expect(reading).resolves.toEqual(fixture.freshEntry); + expect(fixture.released()).toBe(true); + } finally { + dispatch.mockRestore(); + } + }); +}); + const createAcpCapability = (): AcpCapabilityCacheEntry => ({ cliType: 'builtin', agentType: 'codex', @@ -143,7 +350,134 @@ 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 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(), + ], + configOptionsByModel: { + opus: [ + cursorThinkingSelect(), + cursorEffortSelect(['low', 'medium', 'high', 'xhigh', 'max'], 'medium'), + cursorFastSelect(), + ], + 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(), + ], + }, + fetchedAt: 1, +}); + describe('session command helpers', () => { + it.each([{ modelId: 'gpt' }, { configOptionValues: { model: 'gpt' } }])( + 'maps semantic effort against the inherited model: %j', + (inherited) => { + const resolved = resolveSessionCreateDispatchConfig( + { runConfig: { reasoningEffort: 'extra-high' } }, + inherited, + createCursorAcpCapability() + ); + expect(resolved.modelId).toBe('gpt'); + expect(resolved.configOptionValues).toMatchObject({ reasoning: 'extra-high' }); + expect(resolved.configOptionValues).not.toHaveProperty('effort'); + expect(resolved.inheritSessionDefaults).toBe(false); + } + ); + + it('rejects Fast when the inherited model does not support it', () => { + expect(() => + resolveSessionCreateDispatchConfig( + { runConfig: { fastMode: true } }, + { modelId: 'sonnet' }, + createCursorAcpCapability() + ) + ).toThrow(/does not offer a fast mode option/); + }); + + it('lets an explicit model replace inherited model options before semantic mapping', () => { + const resolved = resolveSessionCreateDispatchConfig( + { runConfig: { modelId: 'gpt', reasoningEffort: 'extra-high' } }, + { modelId: 'opus', configOptionValues: { model: 'opus', effort: 'max', fast: 'true' } }, + createCursorAcpCapability() + ); + expect(resolved.modelId).toBe('gpt'); + expect(resolved.configOptionValues).toMatchObject({ reasoning: 'extra-high' }); + expect(resolved.configOptionValues).not.toHaveProperty('model'); + expect(resolved.configOptionValues).not.toHaveProperty('effort'); + }); + it('uses one hard Meta read across request validation and accepted create materialization', async () => { const syncMetaOrThrow = vi.fn(async () => undefined); const manager = { syncMetaOrThrow }; @@ -559,6 +893,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 e74af9d2e..28247e246 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -33,6 +33,10 @@ import { formatSessionQuotaRejection, FREE_SESSION_TURN_LIMIT, isBillingQuotaExempt, + getReadableAcpCapabilityCacheEntry, + isRegistryCursorAgent, + machineSupportsCursorParameterizedModelPicker, + MachineAcpCapabilitiesRefreshResponseSchema, getAcpCapabilityCacheKey, getBuiltinDefaultModeId, getMachineFlockAcpCapabilities, @@ -49,6 +53,8 @@ import { isMachineDocRoomId, isSessionDocRoomId, hasAgentRunConfigSelection, + resolveAcpConfigOptionsForModel, + resolveAcpTargetModelId, resolveAgentRunConfigSelection, resolveBaseBranchPreference, resolveProjectGitHubRepo, @@ -97,6 +103,7 @@ import { type CommonCommandOptions, } from '@/lib/command-runtime'; import { LoroDocumentManager, type SessionDocument } from '@/lib/loro/doc'; +import { withTimeout } from '@/lib/loro/timeout-utils'; import { renderTerminalTable } from '@/lib/terminal-table'; import { canRequestMachineForCliToken, @@ -1372,7 +1379,17 @@ export function applyAgentRunConfigSelection( if (!hasAgentRunConfigSelection(runConfig)) { return { config: rest, validatedConfigIds: new Set(), unverifiedSelections: [] }; } - const resolved = resolveAgentRunConfigSelection(runConfig, capability); + const modelId = + runConfig.modelId ?? + resolveAcpTargetModelId({ + modelId: rest.modelId, + configOptionValues: rest.configOptionValues, + configOptions: capability?.configOptions, + }); + const resolved = resolveAgentRunConfigSelection( + { ...runConfig, ...(modelId ? { modelId } : {}) }, + capability + ); const configOptionValues = { ...(rest.configOptionValues ?? {}), ...(resolved.configOptionValues ?? {}), @@ -1484,16 +1501,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) { @@ -1508,12 +1534,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); @@ -1557,18 +1592,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 } : {}), @@ -1578,15 +1653,22 @@ export function filterCompatibleInheritedTurnConfig( }; } -async function readAgentAcpCapability(args: { +/** Call only after authorizing the target Machine. Cursor migration refreshes automatically. */ +export async function readAgentAcpCapability(args: { + auth: AuthContext; manager: LoroDocumentManager; workspaceId: WorkspaceId; machineId: MachineId; agentConfigId?: AgentConfigMeta['id']; + agent: Pick; }): Promise { if (!args.agentConfigId) { return undefined; } + const configId = args.agentConfigId; + const machine = (await args.manager.repo.getDocMeta(getMachineRoomId(args.machineId)))?.meta as + | MachineMeta + | undefined; await syncMachineFlockDocsForRead( args.manager, args.workspaceId, @@ -1596,10 +1678,77 @@ async function readAgentAcpCapability(args: { const handle = await args.manager.repo.openFlockDoc( getMachineFlockDocId(args.workspaceId, args.machineId) ); - const capabilities = getMachineFlockAcpCapabilities( - readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) - ); - return capabilities[getAcpCapabilityCacheKey(args.agentConfigId)]; + const readReadable = () => { + const entry = getMachineFlockAcpCapabilities( + readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) + )[getAcpCapabilityCacheKey(configId)]; + return entry?.cliType === args.agent.cliType && entry.agentType === args.agent.agentType + ? getReadableAcpCapabilityCacheEntry(entry, machine) + : undefined; + }; + const current = readReadable(); + if ( + current || + !isRegistryCursorAgent(args.agent) || + !machineSupportsCursorParameterizedModelPicker(machine) + ) { + return current; + } + + // The ACK can precede publication, and its compatibility payload omits the + // catalog. Subscribe before probing and wait for the complete current Flock row. + let resolveCurrent!: (entry: AcpCapabilityCacheEntry) => void; + const currentRow = new Promise((resolve) => { + resolveCurrent = resolve; + }); + const observeCurrent = () => { + const entry = readReadable(); + if (entry) resolveCurrent(entry); + }; + const unsubscribeFlock = handle.flock.subscribe(observeCurrent); + let disposed = false; + let room: Awaited> | undefined; + const joining = handle.joinRoom().then((subscription) => { + if (disposed) subscription.unsubscribe(); + else room = subscription; + }); + try { + await withTimeout( + joining, + 8_000, + 'Automatic Cursor capability refresh could not join its Flock room.' + ); + const response = + args.machineId === args.auth.machineId + ? MachineAcpCapabilitiesRefreshResponseSchema.parse( + ( + await dispatchLocalControl({ + type: 'machine/acp-capabilities-refresh', + machineId: args.machineId, + workspaceId: args.workspaceId, + configId, + }) + ).find((message) => message.type === 'machine/acp-capabilities-refresh_response') + ) + : await withMachineRpcClient(args, (client) => + client.requestMachineAcpCapabilitiesRefresh({ configId, timeoutMs: 120_000 }) + ); + if (!response?.success) { + throw new Error( + `Automatic Cursor capability refresh failed: ${response?.error ?? 'no response'}` + ); + } + observeCurrent(); + return await withTimeout( + currentRow, + 8_000, + 'Automatic Cursor capability refresh did not provide a current capability row.' + ); + } finally { + disposed = true; + unsubscribeFlock(); + room?.unsubscribe(); + } } export function resolveTurnDispatchConfigFromInputConfig( @@ -2792,6 +2941,7 @@ export async function validateSessionCreateOptions(args: { ); const resolved = await resolveCreateContext({ ...args, requester }); return await resolveEffectiveSessionCreateDispatchConfig({ + auth: args.auth, manager: args.manager, workspaceId: args.workspace.id as WorkspaceId, agentConfig: resolved.agentConfig, @@ -2801,6 +2951,7 @@ export async function validateSessionCreateOptions(args: { } async function resolveEffectiveSessionCreateDispatchConfig(args: { + auth: AuthContext; manager: LoroDocumentManager; workspaceId: WorkspaceId; agentConfig: AgentConfigMeta; @@ -2828,27 +2979,50 @@ async function resolveEffectiveSessionCreateDispatchConfig(args: { inheritedDispatchConfig?.configOptionValues !== undefined; const capability = needsCapability ? await readAgentAcpCapability({ + auth: args.auth, + agent: args.agentConfig, manager: args.manager, workspaceId: args.workspaceId, machineId: args.agentConfig.machineId, agentConfigId: args.agentConfig.id, }) : undefined; - const requested = applyAgentRunConfigSelection(dispatchConfig, capability); + return withBuiltinDefaultTurnMode( + resolveSessionCreateDispatchConfig(dispatchConfig, inheritedDispatchConfig, capability), + args.agentConfig + ); +} + +export function resolveSessionCreateDispatchConfig( + dispatchConfig: ResolvedTurnDispatchConfig, + inheritedDispatchConfig: ResolvedTurnDispatchConfig | undefined, + capability: AcpCapabilityCacheEntry | undefined +): ResolvedTurnDispatchConfig { + const modelOption = capability?.configOptions?.find((option) => option.category === 'model'); + const optionModelId = modelOption && dispatchConfig.configOptionValues?.[modelOption.id]; + const explicitModelId = + dispatchConfig.runConfig?.modelId ?? + dispatchConfig.modelId ?? + (typeof optionModelId === 'string' ? optionModelId : undefined); + const merged = mergeTurnDispatchConfig( + { ...dispatchConfig, modelId: explicitModelId }, + filterCompatibleInheritedTurnConfig(inheritedDispatchConfig, capability, { + targetModelId: explicitModelId, + }) + ); + const requested = applyAgentRunConfigSelection( + { ...merged, runConfig: dispatchConfig.runConfig }, + capability + ); validateTurnModeAndModel(requested.config, capability); validateTurnConfigOptionValues( requested.config.configOptionValues, capability, - requested.validatedConfigIds + requested.validatedConfigIds, + requested.config.modelId ); return { - ...withBuiltinDefaultTurnMode( - mergeTurnDispatchConfig( - requested.config, - filterCompatibleInheritedTurnConfig(inheritedDispatchConfig, capability) - ), - args.agentConfig - ), + ...requested.config, inheritSessionDefaults: false, }; } @@ -2998,6 +3172,7 @@ export async function createSessionResult( taskId, } = resolved; const effectiveDispatchConfig = await resolveEffectiveSessionCreateDispatchConfig({ + auth, manager, workspaceId: workspace.id as WorkspaceId, agentConfig, @@ -3234,13 +3409,20 @@ export async function sendSessionChatResult( }); if (dispatchConfig.modeId || dispatchConfig.modelId || dispatchConfig.configOptionValues) { const capability = await readAgentAcpCapability({ + auth, + agent: session, manager, workspaceId: workspace.id as WorkspaceId, machineId: session.machineId, agentConfigId: session.agentConfigId, }); validateTurnModeAndModel(dispatchConfig, capability); - validateTurnConfigOptionValues(dispatchConfig.configOptionValues, capability); + validateTurnConfigOptionValues( + dispatchConfig.configOptionValues, + capability, + undefined, + dispatchConfig.modelId + ); } const effectiveDispatchConfig = withBuiltinDefaultTurnMode(dispatchConfig, session); diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index dc7367f87..11c0aeb34 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: UpdateAcpCapabilitiesOptions = {} ): Promise { options.signal?.throwIfAborted(); if (!this.machine) { @@ -3009,6 +3009,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, @@ -3020,6 +3035,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, @@ -3108,7 +3124,7 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, - options: { signal?: AbortSignal } = {} + options: UpdateAcpCapabilitiesOptions = {} ): Promise { options.signal?.throwIfAborted(); const normalizedModes = modes.map((mode) => ({ @@ -3121,6 +3137,22 @@ 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]; + // See AcpCapabilityCatalogWrite: null clears, a map replaces, omitted inherits for + // the same agent identity regardless of sourceVersion. + const configOptionsByModel = + options.configOptionsByModel === null + ? undefined + : options.configOptionsByModel !== undefined + ? options.configOptionsByModel + : existing && existing.cliType === cliType && existing.agentType === agentType + ? existing.configOptionsByModel + : undefined; const entry: AcpCapabilityCacheEntry = { cliType, agentType, @@ -3139,13 +3171,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..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,13 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + type AcpConfigOptionSummary, + type AgentConfigCliType, type AgentConfigId, + getAcpCapabilityCacheKey, + getMachineFlockAcpCapabilities, type MachineFlockKey, type MachineFlockWritableFlock, type MachineId, + readMachineFlockRowsFromFlock, 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(); @@ -169,4 +174,202 @@ 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; + cliType?: AgentConfigCliType; + agentType?: string; + configOptionsByModel?: AcpCapabilityCatalogWrite; + } = {} + ) => + document.updateAcpCapabilities( + 'config-1' as AgentConfigId, + options.cliType ?? 'builtin', + options.agentType ?? '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('keeps 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?.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 () => { + 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/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index cec5d54d3..d0790390c 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -75,7 +75,6 @@ import { MachineUpgradeRequestValidated, MachineUpgradeResponse, MachineAcpCapabilitiesRefreshRequestValidated, - MachineAcpCapabilitiesRefreshResponse, type MachineAcpAuthenticateRequestValidated, type MachineAcpAuthenticateResponse, SessionCodeCollabHostStartRequestValidated, @@ -120,7 +119,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, @@ -285,7 +283,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 { @@ -8418,16 +8420,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/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index b9a785f41..3d98a640e 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -10,7 +10,6 @@ import { getAcpCapabilityCacheKey, getActiveTaskPrLinks, getActiveTaskSessionLinks, - getMachineFlockAcpCapabilities, getMachineFlockDocId, getWorkspaceFlockDocId, getServerNow, @@ -18,7 +17,6 @@ import { hasAgentRunConfigSelection, isLoroRepoDocDeleted, isMachineDocRoomId, - readMachineFlockRowsFromFlock, readWorkspaceFlockRowsFromFlock, listWorkspaceAgentRoles, summarizeAgentRunConfigCapabilities, @@ -119,6 +117,7 @@ import { readLocalProjectGitStateOnMachine, readSessionLiveStatusesMany, readSessionMachineAccess, + readAgentAcpCapability, selectDefaultAgentConfigForCreate, resolveTurnDispatchConfig, sendSessionChatResult, @@ -2412,7 +2411,7 @@ const summarizeAgentConfig = (config: AgentConfigMeta, capability?: AcpCapabilit // not reported capabilities on this Machine yet. // // Reasoning effort and fast mode are per model. Prefer a model entry's own - // reasoningEffortValues; the top-level list and fastMode were measured under + // reasoningEffortValues and optional fastMode; the top-level controls were measured under // measuredForModelId and may differ for another model. runConfig, }; @@ -2424,14 +2423,25 @@ const summarizeAgentConfig = (config: AgentConfigMeta, capability?: AcpCapabilit * advertises a value create would reject. */ const readMachineAcpCapabilities = async ( + auth: AuthContext, manager: LoroDocumentManager, workspaceId: WorkspaceId, - machineId: MachineId + agentConfigs: AgentConfigMeta[] ): Promise> => { - const handle = await manager.repo.openFlockDoc(getMachineFlockDocId(workspaceId, machineId)); - return getMachineFlockAcpCapabilities( - readMachineFlockRowsFromFlock(handle.flock, { families: ['acpCapability'] }) - ); + const capabilities: Record = {}; + // Refresh only the bounded, authorized selection; do not probe the entire Machine catalog. + for (const config of agentConfigs) { + const capability = await readAgentAcpCapability({ + auth, + manager, + workspaceId, + machineId: config.machineId, + agentConfigId: config.id, + agent: config, + }); + if (capability) capabilities[getAcpCapabilityCacheKey(config.id)] = capability; + } + return capabilities; }; const buildSessionWorkContext = async ( @@ -2598,9 +2608,10 @@ const buildSessionCreateOptions = async ( }) .slice(0, MAX_MCP_CREATE_OPTION_MATCHES); const acpCapabilities = await readMachineAcpCapabilities( + auth, manager, workspaceId, - selectedMachine.id + agentConfigs ); const localProjectQuery = normalizeCliValue(input.localProjectQuery)?.toLowerCase(); const currentLocalProjectId = 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..1c54cda75 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,92 @@ 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(), + }); + + expect(calls).toEqual([ + { method: 'unstable_setSessionModel', value: 'model-b' }, + { method: 'setSessionConfigOption', configId: 'thinking', value: 'true' }, + { 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).toEqual([ + { method: 'unstable_setSessionModel', value: 'model-b' }, + { method: 'setSessionConfigOption', configId: 'thinking', value: 'true' }, + ]); + }); + + it('keeps a failed config-option model switch debug-only and still applies remaining options', async () => { + const { agentClient, calls } = createOrderedAgentClient({ + setSessionModel: async () => { + throw new Error('model switch rejected'); + }, + }); + + await expect( + applyAcpSessionRunConfig({ + session: { + sessionId: 'session-8' as SessionId, + acpSessionId: 'acp-8' as ACPSessionId, + agentClient, + }, + config: { + configOptionValues: { + thinking: 'true', + fast: 'true', + model: 'model-b', + }, + }, + logger: createLogger(), + }) + ).resolves.toMatchObject({ + rejectedSelections: [], + warningSelections: [], + }); + + expect(calls.filter((call) => call.method === 'unstable_setSessionModel')).toEqual([ + { method: 'unstable_setSessionModel', value: 'model-b' }, + ]); + expect(calls.filter((call) => call.method === 'setSessionConfigOption')).toEqual([ + { method: 'setSessionConfigOption', configId: 'thinking', value: 'true' }, + { method: 'setSessionConfigOption', configId: 'fast', value: 'true' }, + ]); + }); }); diff --git a/apps/cli/src/session/acp-session-config-applier.ts b/apps/cli/src/session/acp-session-config-applier.ts index 164240551..f32aa9190 100644 --- a/apps/cli/src/session/acp-session-config-applier.ts +++ b/apps/cli/src/session/acp-session-config-applier.ts @@ -159,6 +159,19 @@ export async function applyAcpSessionRunConfig(args: { `[${sessionId}] Failed to set ACP model ${JSON.stringify(config.modelId)}: ${String(error)}` ); } + } else if (typeof configOptionModelId === 'string') { + // The agent validates per-model options against the current model, so the model switch goes first. + try { + await agentClient.unstable_setSessionModel?.(acpSessionId, configOptionModelId); + confirmedLegacyModelId = configOptionModelId; + } catch (error) { + logger.debug( + `[${sessionId}] Failed to set ACP model option ${modelConfigId}=${formatAcpConfigValueForLog( + modelConfigId, + configOptionModelId + )}: ${String(error)}` + ); + } } for (const [configId, value] of configOptionEntries) { @@ -179,19 +192,6 @@ export async function applyAcpSessionRunConfig(args: { continue; } if (configId === modelConfigId) { - if (!config.modelId && typeof value === 'string') { - try { - await agentClient.unstable_setSessionModel?.(acpSessionId, value); - confirmedLegacyModelId = value; - } catch (error) { - logger.debug( - `[${sessionId}] Failed to set ACP model option ${configId}=${formatAcpConfigValueForLog( - configId, - value - )}: ${String(error)}` - ); - } - } continue; } if (shouldSkipFableFastModeDisable({ modelId: targetModelId, configId, value })) { diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 355f26bdf..6bb2bfe96 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, @@ -55,6 +56,7 @@ import { hasBuiltinRuntimeOverrideValues, getManagedBuiltinRuntimeByAgentType, getManagedBuiltinRuntimeByRuntimeName, + isRegistryCursorAgent, serializeCustomAcpLaunchSpec, } from '@lody/shared'; import type { ContentBlock } from '@agentclientprotocol/sdk'; @@ -87,6 +89,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 +102,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'; @@ -580,6 +587,7 @@ export type SessionExecutionServiceDeps = { modes: NonNullable; models: NonNullable; configOptions?: AcpConfigOptionSummary[]; + configOptionsByModel?: AcpCapabilityCatalogWrite; availableCommands?: AcpCommandSummary[]; sessionFork: boolean; acknowledgedSteer: boolean; @@ -592,6 +600,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; @@ -5253,6 +5277,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, @@ -5265,7 +5311,8 @@ export class SessionExecutionService { capabilities.sessionFork, sourceVersion, capabilities.modelReasoningEfforts, - capabilities.acknowledgedSteer + capabilities.acknowledgedSteer, + configOptionsByModel !== undefined ? { configOptionsByModel } : {} ); })().catch((error: unknown) => { this.deps.logger.debug( @@ -5571,6 +5618,7 @@ export class SessionExecutionService { modes, models, configOptions, + configOptionsByModel, availableCommands, sessionFork, acknowledgedSteer, @@ -5614,7 +5662,10 @@ export class SessionExecutionService { }), modelReasoningEfforts, acknowledgedSteer, - { signal: options.signal } + { + signal: options.signal, + ...(configOptionsByModel !== undefined ? { configOptionsByModel } : {}), + } ); return { @@ -5632,7 +5683,7 @@ export class SessionExecutionService { category: opt.category, optionCount: opt.options.length, })), - capability, + capability: toRefreshResponseCapability(capability), availableCommands, }; } catch (error) { diff --git a/apps/cli/tests/agent-setting.test.ts b/apps/cli/tests/agent-setting.test.ts index 6dbe626e4..a5ee642bc 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/apps/cli/tests/message-handler-machine-registration.test.ts b/apps/cli/tests/message-handler-machine-registration.test.ts index f9401161b..4b6588798 100644 --- a/apps/cli/tests/message-handler-machine-registration.test.ts +++ b/apps/cli/tests/message-handler-machine-registration.test.ts @@ -187,6 +187,7 @@ describe('MessageHandler machine registration', () => { localFileResources: 1, providerSetup: 1, acpProtocolAuthentication: 2, + cursorParameterizedModelPicker: 1, }); await handler.cleanup(); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 1d11b4671..6fd59c125 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -14,6 +14,7 @@ import { getMachineRoomId, SessionStatusFactory, type ACPSessionId, + type AcpCapabilityCacheEntry, type AgentConfigMeta, type AgentConfigId, type ChatFailedReason, @@ -31,6 +32,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'; @@ -233,6 +235,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(() => ({ @@ -2551,11 +2716,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> = [ { @@ -6543,6 +6789,142 @@ describe('SessionExecutionService', () => { ); }); + const refreshCursorCapabilities = async ( + models: AcpCapabilityCacheEntry['models'], + configOptionsByModel: AcpCapabilityCacheEntry['configOptionsByModel'] | null + ) => { + const capability: AcpCapabilityCacheEntry = { + cliType: 'registry', + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime', + sourceVersion: 'registry:cursor:unknown', + modes: [], + models, + sessionFork: false, + acknowledgedSteer: false, + sessionForkWorktree: false, + fetchedAt: 1, + ...(configOptionsByModel === null ? {} : { configOptionsByModel }), + }; + const updateAcpCapabilities = vi.fn(async () => capability); + const fetchAcpCapabilities = vi.fn(async () => ({ + modes: [], + models: capability.models, + configOptionsByModel, + sessionFork: false, + acknowledgedSteer: false, + })); + const service = new SessionExecutionService( + 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 result = await service.refreshMachineAcpCapabilities({ + type: 'machine/acp-capabilities-refresh', + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + configId: capabilityConfigId, + }); + return { capability, result, updateAcpCapabilities }; + }; + + it('forwards a confirmed missing Cursor model catalog as a null capability write', async () => { + const { capability, result, updateAcpCapabilities } = await refreshCursorCapabilities( + [{ modelId: 'auto', name: 'Auto' }], + null + ); + + 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('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 { + capability: storedCapability, + result, + updateAcpCapabilities, + } = await refreshCursorCapabilities( + [ + { modelId: 'model-a', name: 'Model A' }, + { modelId: 'model-b', name: 'Model B' }, + ], + catalog + ); + + // 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) => { 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 1aa355fe7..ddad92956 100644 --- a/packages/components/src/components/onboarding/screens/providers-screen.tsx +++ b/packages/components/src/components/onboarding/screens/providers-screen.tsx @@ -794,24 +794,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 1229f2f2b..b5b1544bb 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -2934,8 +2934,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; } @@ -2950,7 +2950,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 07a31123c..7a0c8bd07 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -1197,11 +1197,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 => { @@ -1215,11 +1215,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/AGENTS.md b/packages/components/src/components/shared/AGENTS.md index 495cfdddb..256b50ba9 100644 --- a/packages/components/src/components/shared/AGENTS.md +++ b/packages/components/src/components/shared/AGENTS.md @@ -3,6 +3,12 @@ Parent `AGENTS.md` files also apply. `CLAUDE.md` is a symlink to this file; edit `AGENTS.md` only. +- ACP per-model catalogs follow the composer's input channel: registry/custom agents + prefer the model config option over `selectedModelId` (which can still describe the + live session); builtins use the dedicated model picker. With no selected model, keep + the probe snapshot instead of composing a catalog from its `currentValue`. Preserve + the owning Machine's protocol checks and legacy per-model reasoning ladders. + - `AgentActivityIndicator` animations stay CSS-only and compositor-friendly (`transform`/`opacity`). Do not restore canvas frame loops, React animation state, or timers; keep the Storybook Playwright render budgets passing. diff --git a/packages/components/src/components/shared/acp-selector-options.ts b/packages/components/src/components/shared/acp-selector-options.ts index 1c6b3b31a..8f5d8c844 100644 --- a/packages/components/src/components/shared/acp-selector-options.ts +++ b/packages/components/src/components/shared/acp-selector-options.ts @@ -11,6 +11,7 @@ import { getAcpCapabilityCacheEntryAuthority, getBuiltinDefaultModeId, getStaticBuiltinAcpCapabilities, + resolveAcpConfigOptionsForModel, type AcpCapabilityAuthority, type AgentConfigId, type AgentConfigCliType, @@ -197,7 +198,7 @@ export type AcpSelectorTarget = { selectedModelId?: string | null; configOptionValues?: Record; runtimeOverrides?: BuiltinRuntimeOverrides; - machine?: Pick | null; + machine?: Pick | null; }; /** @@ -211,6 +212,24 @@ type ResolvedConfigOptions = { modelReasoningEfforts: Record | undefined; }; +const isAcpProbedTarget = (target?: Pick): boolean => + target?.cliType === 'registry' || target?.cliType === 'custom'; + +/** Read the channel the composer writes; the live session's model can lag a draft selection. */ +const resolveComposerTargetModelId = ( + configOptions: AcpConfigOptionSummary[] | undefined, + target?: AcpSelectorTarget +): string | undefined => { + const modelOption = configOptions?.find( + (option) => option.category === 'model' && option.type === 'select' + ); + const storedModel = modelOption ? target?.configOptionValues?.[modelOption.id] : undefined; + const fromConfigOption = + typeof storedModel === 'string' && storedModel !== '' ? storedModel : undefined; + const fromPicker = target?.selectedModelId || undefined; + return isAcpProbedTarget(target) ? (fromConfigOption ?? fromPicker) : fromPicker; +}; + const resolveConfigOptions = (target?: AcpSelectorTarget): ResolvedConfigOptions => { if (!target?.cliType || !target.agentType) { return { authority: 'unavailable', modelReasoningEfforts: undefined }; @@ -220,13 +239,25 @@ const resolveConfigOptions = (target?: AcpSelectorTarget): ResolvedConfigOptions const key = getAcpCapabilityCacheKey(target.configId); const capability = getReadableAcpCapabilityCacheEntryForRuntimeOverrides( target.machine?.acpCapabilities?.[key], - target.runtimeOverrides + target.runtimeOverrides, + target.machine ); if (capability) { - const authority = getAcpCapabilityCacheEntryAuthority(capability, target.runtimeOverrides); + const authority = getAcpCapabilityCacheEntryAuthority( + capability, + target.runtimeOverrides, + target.machine + ); const modelReasoningEfforts = capability.modelReasoningEfforts; if (capability.configOptions?.length) { - return { authority, configOptions: capability.configOptions, modelReasoningEfforts }; + return { + authority, + configOptions: resolveAcpConfigOptionsForModel( + capability, + resolveComposerTargetModelId(capability.configOptions, target) + ), + modelReasoningEfforts, + }; } // Fallback: synthesize configOptions from legacy modes/models. const synthesized: AcpConfigOptionSummary[] = []; @@ -259,7 +290,10 @@ const resolveConfigOptions = (target?: AcpSelectorTarget): ResolvedConfigOptions } return { authority, - configOptions: synthesized.length > 0 ? synthesized : undefined, + configOptions: resolveAcpConfigOptionsForModel( + { ...capability, configOptions: synthesized.length > 0 ? synthesized : undefined }, + resolveComposerTargetModelId(synthesized, target) + ), modelReasoningEfforts, }; } @@ -578,7 +612,7 @@ export const buildAcpSelectorOptions = (target?: AcpSelectorTarget): AcpSelector // Custom providers are arbitrary ACP agents just like registry agents: their // modes/models come from the capability probe (configOptions), not the // builtin tables. - const isAcpProbed = target?.cliType === 'registry' || target?.cliType === 'custom'; + const isAcpProbed = isAcpProbedTarget(target); const modeConfigOption = configOptions?.find( (opt) => opt.category === 'mode' && opt.type === 'select' && opt.id !== 'interaction_mode' ); diff --git a/packages/components/src/hooks/use-available-commands.ts b/packages/components/src/hooks/use-available-commands.ts index 91fa36d3b..b7330171e 100644 --- a/packages/components/src/hooks/use-available-commands.ts +++ b/packages/components/src/hooks/use-available-commands.ts @@ -21,7 +21,8 @@ export function useAvailableCommands(target?: AcpSelectorTarget): AcpCommandSumm const key = getAcpCapabilityCacheKey(configId); const capability = getReadableAcpCapabilityCacheEntryForRuntimeOverrides( machine?.acpCapabilities?.[key], - runtimeOverrides + runtimeOverrides, + machine ); return capability?.availableCommands ?? []; }, [configId, cliType, agentType, runtimeOverrides, machine]); diff --git a/packages/components/src/hooks/use-machine-flock-rows.ts b/packages/components/src/hooks/use-machine-flock-rows.ts index 3f8a52562..1560f315b 100644 --- a/packages/components/src/hooks/use-machine-flock-rows.ts +++ b/packages/components/src/hooks/use-machine-flock-rows.ts @@ -2,6 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { getMachineFlockDocId, + getMachineRoomId, + isRegistryCursorAgent, + machineSupportsCursorParameterizedModelPicker, machineFlockKeys, readMachineFlockRowsFromFlock, serializeMachineFlockKey, @@ -11,6 +14,7 @@ import { type MachineFlockRowFamily, type MachineFlockRowMap, type MachineId, + type MachineProtocolCapabilityCarrier, type ReadMachineFlockRowsOptions, } from '@lody/shared'; import { @@ -544,6 +548,25 @@ function readMachineFlockRowsSnapshot( ); } +async function requiresCompleteCursorCapabilityFromFlock( + runtime: Pick, + machineId: MachineId, + capability: AcpCapabilityCacheEntry +): Promise { + if (!isRegistryCursorAgent(capability)) return false; + try { + const entry = await runtime.repo.getDocMeta(getMachineRoomId(machineId)); + if (!entry?.meta) return true; + return machineSupportsCursorParameterizedModelPicker( + entry.meta as MachineProtocolCapabilityCarrier + ); + } catch { + // Unknown ownership metadata cannot prove that the catalog-stripped RPC + // response is complete. Preserve the Flock row without failing refresh. + return true; + } +} + /** * Force a fresh remote sync of a single machine's flock doc and push the result * into any mounted `useMachineFlockRowsByMachineIds` consumers. @@ -581,7 +604,18 @@ export async function resyncMachineFlockRows( syncedRemote: syncResult.syncedRemote, version, }); - if (options.refreshedCapability) { + // Picker-aware Cursor RPC responses omit the per-model catalog for older + // clients. Only their complete Flock row can distinguish an observation from + // a confirmed clear. Legacy Cursor responses remain complete and keep the + // immediate overlay path used when Flock publication is still behind. + const suppressRefreshedCapabilityOverlay = options.refreshedCapability + ? await requiresCompleteCursorCapabilityFromFlock( + runtime, + normalizedMachineId, + options.refreshedCapability.value + ) + : false; + if (options.refreshedCapability && !suppressRefreshedCapabilityOverlay) { const key = machineFlockKeys.acpCapability(options.refreshedCapability.configId); notifyMachineFlockRowsCache(cacheKey, { rows: { diff --git a/packages/components/tests/acp-selector-options.test.ts b/packages/components/tests/acp-selector-options.test.ts index 4f6f236db..0325b472e 100644 --- a/packages/components/tests/acp-selector-options.test.ts +++ b/packages/components/tests/acp-selector-options.test.ts @@ -18,7 +18,7 @@ import { const agentConfigId = 'config-1' as AgentConfigId; const machineWithCapabilities = (acpCapabilities: MachineViewMeta['acpCapabilities']) => - ({ acpCapabilities }) as Pick; + ({ acpCapabilities }) as Pick; const codexMachineWithConfigOptions = (configOptions: AcpConfigOptionSummary[]) => machineWithCapabilities({ @@ -112,6 +112,93 @@ const grokMachineWithLadderProbe = ({ }); describe('buildAcpSelectorOptions', () => { + it.each(['registry', 'custom', 'builtin'] as const)( + 'reads the selected model catalog through the %s model selection channel', + (cliType) => { + const effort: AcpConfigOptionSummary = { + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + options: [{ value: 'high', name: 'High' }], + }; + const fast: AcpConfigOptionSummary = { + id: 'fast', + name: 'Fast', + type: 'boolean', + currentValue: false, + options: [], + }; + const reasoning: AcpConfigOptionSummary = { + ...effort, + id: 'reasoning', + currentValue: 'low', + options: [{ value: 'low', name: 'Low' }], + }; + const target = { + configId: agentConfigId, + cliType, + agentType: 'cursor', + machine: machineWithCapabilities({ + [agentConfigId]: { + cliType, + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + sourceVersion: 'cursor@test+parameterized-model-picker', + fetchedAt: 1, + modes: [], + models: [], + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'a', + options: ['a', 'b', 'empty'].map((value) => ({ value, name: value })), + }, + effort, + fast, + ], + configOptionsByModel: { a: [effort, fast], b: [reasoning], empty: [] }, + }, + }), + }; + for (const selectedModelId of ['a', 'b', 'empty']) { + const selected = + cliType === 'builtin' + ? { ...target, selectedModelId, configOptionValues: { model: 'a' } } + : { ...target, selectedModelId: 'a', configOptionValues: { model: selectedModelId } }; + const expectedIds = + selectedModelId === 'a' + ? ['model', 'effort', 'fast'] + : selectedModelId === 'b' + ? ['model', 'reasoning'] + : ['model']; + const composer = buildAcpSelectorOptions(selected).configOptionSelectors; + const settings = buildAllConfigOptionSelectors(selected); + expect(composer.map((option) => option.configId)).toEqual( + cliType === 'builtin' ? expectedIds.filter((id) => id !== 'model') : expectedIds + ); + expect(settings.map((option) => option.configId)).toEqual(expectedIds); + expect( + settings + .find((option) => option.configId === 'model') + ?.options.map((option) => option.value) + ).toEqual(['a', 'b', 'empty']); + } + // No explicit selection keeps the whole snapshot, including transient values. + // The catalog entry must not replace a snapshot merely because currentValue names a model. + target.machine.acpCapabilities![agentConfigId]!.configOptionsByModel!.a = []; + expect(buildAllConfigOptionSelectors(target).map((option) => option.configId)).toEqual([ + 'model', + 'effort', + 'fast', + ]); + } + ); + it('uses GPT-6 from an older daemon probe instead of the builtin fallback', () => { const options = buildAcpSelectorOptions({ configId: agentConfigId, diff --git a/packages/components/tests/provider-status.test.ts b/packages/components/tests/provider-status.test.ts index 691e0674c..613bbf7b5 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('treats parsed runtime probes as verified across cache versions', () => { - 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('passed'); }); @@ -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-machine-flock-rows.test.tsx b/packages/components/tests/use-machine-flock-rows.test.tsx index f649e39bd..62bff39b8 100644 --- a/packages/components/tests/use-machine-flock-rows.test.tsx +++ b/packages/components/tests/use-machine-flock-rows.test.tsx @@ -7,10 +7,12 @@ import { Provider, createStore } from 'jotai'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ACP_CAPABILITY_CACHE_VERSION, + CURRENT_MACHINE_PROTOCOL_CAPABILITIES, getMachineRoomId, machineFlockKeys, serializeMachineFlockKey, type AgentConfigId, + type AcpCapabilityCacheEntry, type AgentConfigMeta, type LocalProjectId, type MachineId, @@ -299,7 +301,12 @@ describe('useMachineFlockRows', () => { const runtime = { workspaceId, workspaceSlug, - repo: { openFlockDoc: vi.fn(async () => handle) }, + repo: { + getDocMeta: vi.fn(async (docId) => + docId === getMachineRoomId(machineId) ? { meta: { id: machineId } } : undefined + ), + openFlockDoc: vi.fn(async () => handle), + }, } as unknown as WorkspaceRuntime; store.set(runtimeAtom, runtime); store.set(currentWorkspaceIdAtom, workspaceId); @@ -328,6 +335,109 @@ describe('useMachineFlockRows', () => { const capabilityRowId = serializeMachineFlockKey(machineFlockKeys.acpCapability(configId)); expect(updates.at(-1)?.[capabilityRowId]?.value).toEqual(capability); + + const legacyCursorCapability = { + ...capability, + agentType: 'cursor', + sourceVersion: 'registry:cursor:legacy-test', + }; + await resyncMachineFlockRows(runtime, machineId, { + requireRemoteSync: true, + refreshedCapability: { configId, value: legacyCursorCapability }, + }); + await flushMicrotasks(); + expect(updates.at(-1)?.[capabilityRowId]?.value).toEqual(legacyCursorCapability); + }); + + it('keeps the complete Cursor row on refresh and observes a subsequent catalog clear', async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const store = createStore(); + const workspaceId = 'workspace-cursor-refresh-catalog' as WorkspaceId; + const machineId = 'machine-cursor-refresh-catalog' as MachineId; + const configId = 'config-cursor-refresh-catalog' as AgentConfigId; + const key = machineFlockKeys.acpCapability(configId); + const rowId = serializeMachineFlockKey(key); + const stripped: AcpCapabilityCacheEntry = { + cliType: 'registry', + agentType: 'cursor', + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + sourceVersion: 'cursor@test+parameterized-model-picker', + fetchedAt: 2, + modes: [], + models: [], + configOptions: [], + }; + let current: AcpCapabilityCacheEntry = { ...stripped, configOptionsByModel: { a: [] } }; + let emitFlockBatch: ((batch: { events: MachineFlockEvent[] }) => void) | undefined; + const handle = { + flock: { + scan: () => [{ key, value: current }], + subscribe: (listener: (batch: { events: MachineFlockEvent[] }) => void) => { + emitFlockBatch = listener; + return () => undefined; + }, + }, + syncOnce: async () => ({ ok: true, transports: [] }), + }; + const getDocMeta = vi.fn(async () => ({ + meta: { protocolCapabilities: CURRENT_MACHINE_PROTOCOL_CAPABILITIES }, + })); + const runtime = { + workspaceId, + workspaceSlug: workspaceId, + repo: { + getDocMeta, + openFlockDoc: async () => handle, + }, + } as unknown as WorkspaceRuntime; + store.set(runtimeAtom, runtime); + store.set(currentWorkspaceIdAtom, workspaceId); + store.set(currentWorkspaceSlugAtom, workspaceId); + const updates: MachineFlockRowMap[] = []; + render( + createElement( + Provider, + { store }, + createElement(RowsProbe, { + machineId, + remoteMachineIds: [], + families: ['acpCapability'], + onRows: (rows) => updates.push(rows), + }) + ) + ); + await flushMicrotasks(); + await act(async () => { + await resyncMachineFlockRows(runtime, machineId, { + refreshedCapability: { configId, value: stripped }, + }); + }); + expect(updates.at(-1)?.[rowId]?.value).toEqual(current); + expect(updates.at(-1)?.[rowId]?.value).toHaveProperty('configOptionsByModel', { a: [] }); + + // An RPC can finish before the room delivers its row. The later Flock + // observation must replace the catalog without another manual refresh. + getDocMeta.mockRejectedValueOnce(new Error('machine metadata temporarily unavailable')); + await act(async () => { + await resyncMachineFlockRows(runtime, machineId, { + refreshedCapability: { configId, value: { ...stripped, fetchedAt: 3 } }, + }); + }); + expect(updates.at(-1)?.[rowId]?.value).toEqual(current); + current = { ...stripped, fetchedAt: 3, configOptionsByModel: { b: [] } }; + act(() => emitFlockBatch?.({ events: [{ key, value: current }] })); + expect(updates.at(-1)?.[rowId]?.value).toEqual(current); + + // A confirmed -32601 writes a complete row without a catalog. Never merge + // an older catalog back into this new observation. + current = { ...stripped, fetchedAt: 4 }; + await act(async () => { + await resyncMachineFlockRows(runtime, machineId, { + refreshedCapability: { configId, value: current }, + }); + }); + expect(updates.at(-1)?.[rowId]?.value).toEqual(current); + expect(updates.at(-1)?.[rowId]?.value).not.toHaveProperty('configOptionsByModel'); }); it('resolves ACP capabilities from Machine Flock over legacy machine meta', async () => { diff --git a/packages/shared/AGENTS.md b/packages/shared/AGENTS.md index d224cb46c..2a8a07065 100644 --- a/packages/shared/AGENTS.md +++ b/packages/shared/AGENTS.md @@ -71,7 +71,9 @@ per-turn MCP selection, or Role-based session creation and dispatch. - ACP capability `cacheVersion` controls refresh freshness, never readability. Consumers preserve understood fields from parsed older or newer entries during mixed-version operation, adapting only fields with known incompatible semantics; runtime-override source - matching remains a separate applicability gate. + matching remains a separate applicability gate. Registry Cursor rows without the picker + source marker are incompatible only when their owning Machine advertises the picker + protocol; readers and freshness checks share that applicability rule. ## Workspace MCP and Agent Roles diff --git a/packages/shared/src/acp-run-config.ts b/packages/shared/src/acp-run-config.ts index e5ad11924..d3dd69ae7 100644 --- a/packages/shared/src/acp-run-config.ts +++ b/packages/shared/src/acp-run-config.ts @@ -33,6 +33,32 @@ export const ACP_THOUGHT_LEVEL_CATEGORY = 'thought_level'; export const ACP_CONFIG_OPTION_ON_VALUE = 'on'; export const ACP_CONFIG_OPTION_OFF_VALUE = 'off'; +const ACP_CONFIG_OPTION_TRUE_VALUE = 'true'; +const ACP_CONFIG_OPTION_FALSE_VALUE = 'false'; + +export const isAcpOnOffSelectValues = (values: readonly string[]): boolean => + values.includes(ACP_CONFIG_OPTION_ON_VALUE) && values.includes(ACP_CONFIG_OPTION_OFF_VALUE); + +/** cursor-agent emits boolean parameters as a two-row select with these exact values. */ +export const isAcpTrueFalseSelectValues = (values: readonly string[]): boolean => + values.length === 2 && + values.includes(ACP_CONFIG_OPTION_TRUE_VALUE) && + values.includes(ACP_CONFIG_OPTION_FALSE_VALUE); + +export const isAcpToggleSelectValues = (values: readonly string[]): boolean => + isAcpOnOffSelectValues(values) || isAcpTrueFalseSelectValues(values); + +export const isAcpToggleSelectEnabledValue = (value: unknown): boolean => + value === ACP_CONFIG_OPTION_ON_VALUE || value === ACP_CONFIG_OPTION_TRUE_VALUE; + +export const toggleAcpSelectOptionValue = (values: readonly string[], enabled: boolean): string => + isAcpTrueFalseSelectValues(values) + ? enabled + ? ACP_CONFIG_OPTION_TRUE_VALUE + : ACP_CONFIG_OPTION_FALSE_VALUE + : enabled + ? ACP_CONFIG_OPTION_ON_VALUE + : ACP_CONFIG_OPTION_OFF_VALUE; /** Permission mode id that means "plan without editing" across builtin agents. */ export const ACP_PLAN_PERMISSION_MODE_ID = 'plan'; @@ -66,9 +92,16 @@ 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[] }>; + models: Array<{ + id: string; + name: string; + reasoningEffortValues?: string[]; + /** Absent when no per-model catalog observation is available. */ + fastMode?: boolean; + }>; /** * Effort values the agent reported for `measuredForModelId`. Agents rebuild * this list on every model switch, so it only describes that one model. @@ -102,7 +135,7 @@ export type AgentRunConfigResolution = { type RunConfigCapabilitySource = Pick< AcpCapabilityCacheEntry, - 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' + 'modes' | 'models' | 'configOptions' | 'modelReasoningEfforts' | 'configOptionsByModel' >; /** @@ -147,13 +180,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' && @@ -163,24 +195,54 @@ const isCollaborationModeSelect = (option: AcpConfigOptionSummary): boolean => const toggleValue = (option: AcpConfigOptionSummary, enabled: boolean): AcpConfigOptionValue => option.type === 'boolean' ? enabled - : enabled - ? ACP_CONFIG_OPTION_ON_VALUE - : ACP_CONFIG_OPTION_OFF_VALUE; + : toggleAcpSelectOptionValue(selectOptionValues(option), enabled); + +const isMultiLevelReasoningEffortSelect = (option: AcpConfigOptionSummary): boolean => + option.type === 'select' && + isAcpThoughtLevelConfigOption(option) && + !isAcpToggleSelectValues(selectOptionValues(option)); + +const isThoughtLevelSelect = (option: AcpConfigOptionSummary): boolean => + option.type === 'select' && isAcpThoughtLevelConfigOption(option); + +/** + * A model that publishes both a thinking toggle and an effort ladder (Cursor) + * maps `reasoningEffort` onto the ladder; a model whose only thought control is + * the toggle (Kimi without effort levels) keeps mapping onto it. + */ +const findReasoningEffortOptionIn = ( + configOptions: readonly AcpConfigOptionSummary[] | undefined +): AcpConfigOptionSummary | undefined => + configOptions?.find(isMultiLevelReasoningEffortSelect) ?? + configOptions?.find(isThoughtLevelSelect); + +const findFastModeOptionIn = ( + configOptions: readonly AcpConfigOptionSummary[] | undefined +): AcpConfigOptionSummary | undefined => + configOptions?.find((option) => isAcpFastModeConfigId(option.id) && isToggleOption(option)); + +const hasPerModelCatalogEntry = ( + capability: RunConfigCapabilitySource | undefined, + modelId: string | undefined +): boolean => { + const catalog = capability?.configOptionsByModel; + return catalog !== undefined && typeof modelId === 'string' && catalog[modelId] !== undefined; +}; const findFastModeOption = ( - capability: RunConfigCapabilitySource | undefined + capability: RunConfigCapabilitySource | undefined, + targetModelId?: string ): AcpConfigOptionSummary | undefined => - findConfigOption( - capability, - (option) => isAcpFastModeConfigId(option.id) && isToggleOption(option) + findFastModeOptionIn( + capability ? resolveAcpConfigOptionsForModel(capability, targetModelId) : undefined ); const findReasoningEffortOption = ( - capability: RunConfigCapabilitySource | undefined + capability: RunConfigCapabilitySource | undefined, + targetModelId?: string ): AcpConfigOptionSummary | undefined => - findConfigOption( - capability, - (option) => option.type === 'select' && isAcpThoughtLevelConfigOption(option) + findReasoningEffortOptionIn( + capability ? resolveAcpConfigOptionsForModel(capability, targetModelId) : undefined ); const findPlanModeOption = ( @@ -261,8 +323,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 (!capability || !hasPerModelCatalogEntry(capability, model.id)) { + return legacyEfforts ? { ...model, reasoningEffortValues: legacyEfforts } : model; + } + const catalogEfforts = findReasoningEffortOptionIn( + resolveAcpConfigOptionsForModel(capability, model.id) + )?.options.map((value) => value.value); + return { + ...model, + ...(legacyEfforts || catalogEfforts + ? { reasoningEffortValues: legacyEfforts ?? catalogEfforts } + : {}), + fastMode: findFastModeOption(capability, model.id) !== undefined, + }; }), reasoningEffortValues: (findReasoningEffortOption(capability)?.options ?? []).map( (value) => value.value @@ -313,7 +387,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; @@ -330,6 +404,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); @@ -338,14 +420,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}`); } } @@ -371,3 +451,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 29f69904c..e8f11f226 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -12,6 +12,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, @@ -310,6 +314,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`. */ @@ -323,9 +335,50 @@ 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 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; + +const hasIncompatibleCursorModelIds = ( + entry: AcpCapabilityCacheEntry, + machine: AcpCapabilityMachine +): boolean => + isRegistryCursorAgent(entry) && + machineSupportsCursorParameterizedModelPicker(machine) && + entry.sourceVersion?.endsWith(CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX) !== true; + export const isAcpCapabilityCacheEntryCurrent = ( - entry: AcpCapabilityCacheEntry | undefined -): entry is AcpCapabilityCacheEntry => entry?.cacheVersion === ACP_CAPABILITY_CACHE_VERSION; + entry: AcpCapabilityCacheEntry | undefined, + machine: AcpCapabilityMachine +): entry is AcpCapabilityCacheEntry => { + if (entry?.cacheVersion !== ACP_CAPABILITY_CACHE_VERSION) { + return false; + } + return !hasIncompatibleCursorModelIds(entry, machine); +}; /** * A parsed capability entry remains readable regardless of the producer's cache version. @@ -333,9 +386,10 @@ export const isAcpCapabilityCacheEntryCurrent = ( * keep using fields they understand while a newer probe converges the stored entry. */ export const getReadableAcpCapabilityCacheEntry = ( - entry: AcpCapabilityCacheEntry | undefined + entry: AcpCapabilityCacheEntry | undefined, + machine: AcpCapabilityMachine ): AcpCapabilityCacheEntry | undefined => { - if (!entry) { + if (!entry || hasIncompatibleCursorModelIds(entry, machine)) { return undefined; } // Cache v7 stopped deriving bracketed model suffixes as reasoning efforts for @@ -354,9 +408,10 @@ export const getReadableAcpCapabilityCacheEntry = ( export const getReadableAcpCapabilityCacheEntryForRuntimeOverrides = ( entry: AcpCapabilityCacheEntry | undefined, - runtimeOverrides: BuiltinRuntimeOverrides | undefined + runtimeOverrides: BuiltinRuntimeOverrides | undefined, + machine: AcpCapabilityMachine ): AcpCapabilityCacheEntry | undefined => { - const readableEntry = getReadableAcpCapabilityCacheEntry(entry); + const readableEntry = getReadableAcpCapabilityCacheEntry(entry, machine); if (!readableEntry) { return undefined; } @@ -368,9 +423,10 @@ export const getReadableAcpCapabilityCacheEntryForRuntimeOverrides = ( 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); @@ -379,11 +435,13 @@ export const isAcpCapabilityCacheEntryCurrentForRuntimeOverrides = ( export const getAcpCapabilityCacheEntryAuthority = ( entry: AcpCapabilityCacheEntry | undefined, - runtimeOverrides: BuiltinRuntimeOverrides | undefined + runtimeOverrides: BuiltinRuntimeOverrides | undefined, + machine: AcpCapabilityMachine ): AcpCapabilityAuthority => { const readableEntry = getReadableAcpCapabilityCacheEntryForRuntimeOverrides( entry, - runtimeOverrides + runtimeOverrides, + machine ); if (!readableEntry) { return 'unavailable'; @@ -398,12 +456,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 f81e8dde5..2bf0d7ead 100644 --- a/packages/shared/src/machine-protocol-capabilities.ts +++ b/packages/shared/src/machine-protocol-capabilities.ts @@ -13,6 +13,7 @@ export const MACHINE_PROTOCOL_CAPABILITIES = { providerSetup: 'providerSetup', localFileResources: 'localFileResources', acpProtocolAuthentication: 'acpProtocolAuthentication', + cursorParameterizedModelPicker: 'cursorParameterizedModelPicker', } as const; export const ACP_AUTHENTICATION_INTERACTIONS_PROTOCOL_VERSION = 2; @@ -20,8 +21,9 @@ export const LOCAL_PROJECT_REMOVAL_PROTOCOL_VERSION = 1; export const PROVIDER_SETUP_PROTOCOL_VERSION = 1; export const LOCAL_FILE_RESOURCES_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; }; @@ -55,6 +57,8 @@ export const CURRENT_MACHINE_PROTOCOL_CAPABILITIES: MachineProtocolCapabilities [MACHINE_PROTOCOL_CAPABILITIES.providerSetup]: PROVIDER_SETUP_PROTOCOL_VERSION, [MACHINE_PROTOCOL_CAPABILITIES.localFileResources]: LOCAL_FILE_RESOURCES_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. */ @@ -106,6 +110,23 @@ export function machineSupportsAcpProtocolAuthentication( ); } +/** + * 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 + ); +} + export function machineSupportsLocalFileResourcesProtocol( machine: MachineProtocolCapabilityCarrier | null | undefined ): boolean { diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 68f4ef21d..5b0d9719c 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -1210,6 +1210,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/acp-capability-cache.test.ts b/packages/shared/tests/acp-capability-cache.test.ts new file mode 100644 index 000000000..8ec7e5b84 --- /dev/null +++ b/packages/shared/tests/acp-capability-cache.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +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 & + Partial> +): AcpCapabilityCacheEntry => ({ + cacheVersion: ACP_CAPABILITY_CACHE_VERSION, + provenance: 'runtime', + modes: [], + models: [], + fetchedAt: 1, + ...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); + 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('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 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 on a picker machine', () => { + const entry = cacheEntry({ + cliType: 'registry', + agentType: 'cursor', + sourceVersion: undefined, + }); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).toBe(false); + }); + + it('accepts a registry Cursor row that carries the marker', () => { + 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', () => { + const entry = cacheEntry({ + cliType: 'registry', + agentType: 'gemini', + sourceVersion: 'gemini@1.0.0', + }); + expect(isAcpCapabilityCacheEntryCurrent(entry, pickerMachine)).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, pickerMachine)).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, pickerMachine)).toBe(false); + expect(isAcpCapabilityCacheEntryCurrent(entry, legacyMachine)).toBe(false); + }); +}); diff --git a/packages/shared/tests/acp-run-config.test.ts b/packages/shared/tests/acp-run-config.test.ts index 0e9ba7b1a..67e7233c9 100644 --- a/packages/shared/tests/acp-run-config.test.ts +++ b/packages/shared/tests/acp-run-config.test.ts @@ -3,10 +3,17 @@ import { createPlanModeConfigOption } from 'acp-extension-core'; import { deriveModelReasoningEffortsFromLegacyModelIds, + isAcpOnOffSelectValues, + isAcpToggleSelectEnabledValue, + isAcpTrueFalseSelectValues, + resolveAcpConfigOptionsForModel, + resolveAcpTargetModelId, getStaticBuiltinAcpCapabilities, resolveAgentRunConfigSelection, summarizeAgentRunConfigCapabilities, + toggleAcpSelectOptionValue, type AcpCapabilityCacheEntry, + type AcpConfigOptionSummary, } from '../src'; describe('builtin independent Plan preflight', () => { @@ -387,4 +394,426 @@ 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 select = ( + id: string, + name: string, + category: AcpConfigOptionSummary['category'], + currentValue: string, + values: readonly string[], + optionNames: readonly string[] = values +): AcpConfigOptionSummary => ({ + id, + name, + ...(category === undefined ? {} : { category }), + type: 'select', + currentValue, + options: values.map((value, index) => ({ value, name: optionNames[index] ?? value })), +}); + +const modelSelect = (currentValue = 'a') => + select('model', 'Model', 'model', currentValue, ['a', 'b', 'c'], ['A', 'B', 'C']); + +const modeSelect = () => select('mode', 'Mode', 'mode', 'agent', ['agent'], ['Agent']); + +const thinkingSelect = () => + select('thinking', 'Thinking', 'thought_level', 'false', ['true', 'false'], ['On', 'Off']); + +const effortSelect = () => + select('effort', 'Effort', 'thought_level', 'low', ['low', 'high'], ['Low', 'High']); + +const fastSelect = () => + select('fast', 'Fast', 'model_config', 'false', ['true', 'false'], ['On', 'Off']); + +const reasoningSelect = () => + select( + 'reasoning', + 'Reasoning', + 'thought_level', + 'minimal', + ['minimal', 'full'], + ['Minimal', 'Full'] + ); + +const contextSelect = () => + select('context', 'Context', undefined, 'default', ['default'], ['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', () => { + const resolveTarget = (modelId?: string, modelValue?: string | boolean) => + resolveAcpTargetModelId({ + ...(modelId === undefined ? {} : { modelId }), + ...(modelValue === undefined ? {} : { configOptionValues: { model: modelValue } }), + configOptions: [modelSelect('from-current')], + }); + + it('resolves explicit, configured, and current model values in priority order', () => { + expect(resolveTarget('explicit', 'from-values')).toBe('explicit'); + expect(resolveTarget(undefined, 'from-values')).toBe('from-values'); + expect(resolveTarget()).toBe('from-current'); + expect(resolveTarget('', 'from-values')).toBe('from-values'); + expect(resolveTarget(undefined, true)).toBe('from-current'); + expect(resolveTarget(undefined, '')).toBe('from-current'); + }); +}); + +describe('ACP toggle select predicates', () => { + it('recognises on/off selects that include extra values', () => { + expect(isAcpOnOffSelectValues(['off', 'on', 'auto'])).toBe(true); + }); + + it('recognises exactly the true/false select set', () => { + expect(isAcpTrueFalseSelectValues(['true', 'false'])).toBe(true); + expect(isAcpTrueFalseSelectValues(['false', 'true'])).toBe(true); + expect(isAcpTrueFalseSelectValues(['true', 'false', 'auto'])).toBe(false); + expect(isAcpTrueFalseSelectValues(['true', 'true'])).toBe(false); + expect(isAcpTrueFalseSelectValues(['True', 'False'])).toBe(false); + }); + + it('writes the advertised toggle representation', () => { + expect(toggleAcpSelectOptionValue(['true', 'false'], true)).toBe('true'); + expect(toggleAcpSelectOptionValue(['on', 'off'], false)).toBe('off'); + }); + + it('treats only the advertised enabled strings as on', () => { + expect(isAcpToggleSelectEnabledValue('true')).toBe(true); + expect(isAcpToggleSelectEnabledValue('on')).toBe(true); + expect(isAcpToggleSelectEnabledValue('false')).toBe(false); + expect(isAcpToggleSelectEnabledValue(true)).toBe(false); + }); +}); + +const cursorThinkingSelect = () => + select('thinking', 'Thinking', 'thought_level', 'false', ['false', 'true'], ['Off', 'On']); + +const cursorEffortSelect = ( + values: readonly string[], + currentValue: string +): AcpConfigOptionSummary => select('effort', 'Effort', 'thought_level', currentValue, values); + +const cursorFastSelect = () => + select('fast', 'Fast', 'model_config', 'false', ['false', 'true'], ['Off', 'On']); + +const cursorContextSelect = () => + select('context', 'Context', 'model_config', 'default', ['default'], ['Default']); + +const cursorReasoningSelect = () => + select( + 'reasoning', + 'Reasoning', + 'thought_level', + 'low', + ['low', 'medium', 'high', 'extra-high'], + ['Low', 'Medium', 'High', 'Extra high'] + ); + +const cursorModelSelect = () => + select( + 'model', + 'Model', + 'model', + 'opus', + ['opus', 'sonnet', 'gemini', 'gpt', 'empty'], + ['Opus', 'Sonnet', 'Gemini', 'GPT', '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('publishes Fast availability for every known model without guessing unknown models', () => { + const capability = cursorCatalogCapability(); + const models = summarizeAgentRunConfigCapabilities(capability).models; + expect(models.map(({ id, fastMode }) => ({ id, fastMode }))).toEqual([ + { id: 'opus', fastMode: true }, + { id: 'sonnet', fastMode: false }, + { id: 'gemini', fastMode: false }, + { id: 'gpt', fastMode: true }, + { id: 'empty', fastMode: false }, + ]); + delete capability.configOptionsByModel; + expect( + summarizeAgentRunConfigCapabilities(capability).models.every( + (model) => !('fastMode' in model) + ) + ).toBe(true); + }); + + 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', + ]); + }); }); diff --git a/packages/shared/tests/ai-capability-cache.test.ts b/packages/shared/tests/ai-capability-cache.test.ts index 9e3fe9d67..0bb86bd64 100644 --- a/packages/shared/tests/ai-capability-cache.test.ts +++ b/packages/shared/tests/ai-capability-cache.test.ts @@ -8,6 +8,7 @@ import { isAcpCapabilityCacheEntryCurrent, type AcpCapabilityCacheEntry, } from '../src/ai'; +import { CURRENT_MACHINE_PROTOCOL_CAPABILITIES } from '../src/machine-protocol-capabilities'; const entry = (cacheVersion?: number): AcpCapabilityCacheEntry => ({ cliType: 'builtin', @@ -26,33 +27,43 @@ describe('ACP capability cache compatibility', () => { (cacheVersion) => { const capability = entry(cacheVersion); - expect(getReadableAcpCapabilityCacheEntry(capability)).toBeDefined(); - expect(getAcpCapabilityCacheEntryAuthority(capability, undefined)).toBe('authoritative'); + expect(getReadableAcpCapabilityCacheEntry(capability, undefined)).toBeDefined(); + expect(getAcpCapabilityCacheEntryAuthority(capability, undefined, undefined)).toBe( + 'authoritative' + ); } ); it('keeps freshness separate from readability', () => { const capability = entry(ACP_CAPABILITY_CACHE_VERSION - 1); - expect(isAcpCapabilityCacheEntryCurrent(capability)).toBe(false); - expect(getAcpCapabilityCacheStaleReason(capability, capability.sourceVersion!)).toBe( + expect(isAcpCapabilityCacheEntryCurrent(capability, undefined)).toBe(false); + expect(getAcpCapabilityCacheStaleReason(capability, capability.sourceVersion!, undefined)).toBe( 'cache-version-mismatch' ); - expect(getReadableAcpCapabilityCacheEntry(capability)).toBeDefined(); + expect(getReadableAcpCapabilityCacheEntry(capability, undefined)).toBeDefined(); }); it('retains the runtime-override applicability gate for readable entries', () => { const capability = entry(ACP_CAPABILITY_CACHE_VERSION - 1); expect( - getReadableAcpCapabilityCacheEntryForRuntimeOverrides(capability, { - codexPath: '/opt/codex', - }) + getReadableAcpCapabilityCacheEntryForRuntimeOverrides( + capability, + { + codexPath: '/opt/codex', + }, + undefined + ) ).toBeDefined(); expect( - getReadableAcpCapabilityCacheEntryForRuntimeOverrides(capability, { - codexPath: '/different/codex', - }) + getReadableAcpCapabilityCacheEntryForRuntimeOverrides( + capability, + { + codexPath: '/different/codex', + }, + undefined + ) ).toBeUndefined(); }); @@ -65,6 +76,44 @@ describe('ACP capability cache compatibility', () => { }; const { modelReasoningEfforts: _incompatibleModelReasoningEfforts, ...compatible } = capability; - expect(getReadableAcpCapabilityCacheEntry(capability)).toEqual(compatible); + expect(getReadableAcpCapabilityCacheEntry(capability, undefined)).toEqual(compatible); }); }); + +describe('Cursor cache compatibility follows the owning machine protocol', () => { + const pickerMachine = { protocolCapabilities: CURRENT_MACHINE_PROTOCOL_CAPABILITIES }; + it.each([ + undefined, + ACP_CAPABILITY_CACHE_VERSION - 1, + ACP_CAPABILITY_CACHE_VERSION, + ACP_CAPABILITY_CACHE_VERSION + 1, + ])( + 'keeps compatible Cursor cache version %s readable while rejecting pre-picker IDs', + (cacheVersion) => { + const unmarked: AcpCapabilityCacheEntry = { + ...entry(cacheVersion), + cliType: 'registry', + agentType: 'cursor', + sourceVersion: 'cursor@test', + }; + const marked = { ...unmarked, sourceVersion: 'cursor@test+parameterized-model-picker' }; + expect(getReadableAcpCapabilityCacheEntry(marked, pickerMachine)).toEqual(marked); + expect(getAcpCapabilityCacheEntryAuthority(marked, undefined, pickerMachine)).toBe( + 'authoritative' + ); + expect(isAcpCapabilityCacheEntryCurrent(marked, pickerMachine)).toBe( + cacheVersion === ACP_CAPABILITY_CACHE_VERSION + ); + expect(getReadableAcpCapabilityCacheEntry(unmarked, pickerMachine)).toBeUndefined(); + expect(getAcpCapabilityCacheEntryAuthority(unmarked, undefined, pickerMachine)).toBe( + 'unavailable' + ); + expect( + getReadableAcpCapabilityCacheEntryForRuntimeOverrides(unmarked, undefined, pickerMachine) + ).toBeUndefined(); + expect(getReadableAcpCapabilityCacheEntry(unmarked, undefined)).toEqual(unmarked); + const custom = { ...unmarked, cliType: 'custom' as const }; + expect(getReadableAcpCapabilityCacheEntry(custom, pickerMachine)).toEqual(custom); + } + ); +}); diff --git a/packages/shared/tests/message-schemas.test.ts b/packages/shared/tests/message-schemas.test.ts index 3d695f0a8..2c2882246 100644 --- a/packages/shared/tests/message-schemas.test.ts +++ b/packages/shared/tests/message-schemas.test.ts @@ -314,6 +314,71 @@ describe('message-schemas machine ACP capabilities refresh', () => { response.capability ); }); + + const createMinimalRefreshResponse = (capabilityFields: Record) => ({ + 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' }], + sessionFork: false, + fetchedAt: 1, + ...capabilityFields, + }, + }); + + 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 = createMinimalRefreshResponse({ + 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, + }); + + expect(MachineAcpCapabilitiesRefreshResponseSchema.safeParse(response).success).toBe(true); + expect( + MachineAcpCapabilitiesRefreshResponseSchema.parse(response).capability?.configOptionsByModel + ).toEqual(configOptionsByModel); + }); + + it('still rejects unknown capability fields', () => { + const response = createMinimalRefreshResponse({ catalogByModel: {} }); + + expect(MachineAcpCapabilitiesRefreshResponseSchema.safeParse(response).success).toBe(false); + }); }); describe('message-schemas machine ACP authentication', () => { diff --git a/specs/acp-capability-cache-compatibility.md b/specs/acp-capability-cache-compatibility.md index 85dd9f441..65fed3837 100644 --- a/specs/acp-capability-cache-compatibility.md +++ b/specs/acp-capability-cache-compatibility.md @@ -16,6 +16,11 @@ source-specific: capability data collected for a different override must not be because its structure is readable. Data that fails the wire or storage schema is outside this guarantee and may be rejected at the parsing boundary. +Registry Cursor model IDs are a known protocol incompatibility: a Machine advertising the +parameterized picker cannot use a row produced before that opt-in. Readers require the picker +source marker on that Machine and refresh an incompatible row; a legacy Machine keeps using its +unmarked variant IDs. A marked row remains readable across cache versions. + Evidence: `packages/shared/tests/ai-capability-cache.test.ts`, `packages/components/tests/acp-selector-options.test.ts`, and `packages/components/tests/provider-status.test.ts`. Draft for human review; tests do not grant