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/mobile/AGENTS.md b/packages/components/src/components/mobile/AGENTS.md index 8f209417c..0e9cfdbf0 100644 --- a/packages/components/src/components/mobile/AGENTS.md +++ b/packages/components/src/components/mobile/AGENTS.md @@ -91,6 +91,7 @@ before touching gestures. ## Run config, pickers, and sheets +- Thinking/Fast rows follow `../shared/AGENTS.md`. - ONE control (`mobile-session-run-config.tsx`) serves both the in-session composer and the new-chat sheet: it takes `agentSelection` (no SessionMeta dependency) plus model/mode/config props. Explicit permission selectors diff --git a/packages/components/src/components/mobile/mobile-run-config-button.tsx b/packages/components/src/components/mobile/mobile-run-config-button.tsx index 8051d5fb1..331ff25f2 100644 --- a/packages/components/src/components/mobile/mobile-run-config-button.tsx +++ b/packages/components/src/components/mobile/mobile-run-config-button.tsx @@ -1,5 +1,5 @@ import { useMemo, type ReactNode } from 'react'; -import { ListChecks, Zap } from 'lucide-react'; +import { Brain, ListChecks, Zap } from 'lucide-react'; import { classifyPermissionModeFace } from '@lody/shared'; import { @@ -67,6 +67,7 @@ export function useRunConfigFace({ permissionModeSelectors, modeSelectors, thoughtLevelSelectors, + thoughtToggleSelectors, planModeSelectors, fastModeSelectors, } = useMemo(() => orderAcpConfigOptionSelectors(configOptionSelectors), [configOptionSelectors]); @@ -125,8 +126,11 @@ export function useRunConfigFace({ const fastOn = fastSelector ? resolveOnOffConfigOptionEnabled(fastSelector, configOptionValues?.[fastSelector.configId]) : false; + const thoughtToggleOn = thoughtToggleSelectors.some((selector) => + resolveOnOffConfigOptionEnabled(selector, configOptionValues?.[selector.configId]) + ); - return { modelLabel, thinkingLabel, modeId, planOn, fastOn }; + return { modelLabel, thinkingLabel, modeId, planOn, fastOn, thoughtToggleOn }; } /** Middle-dot separator between the face's identity/status groups. */ @@ -145,8 +149,9 @@ export function MobileRunConfigButton({ ariaLabel = 'Run configuration', ...faceProps }: MobileRunConfigButtonProps) { - const { modelLabel, thinkingLabel, modeId, planOn, fastOn } = useRunConfigFace(faceProps); - const hasToggle = planOn || fastOn; + const { modelLabel, thinkingLabel, modeId, planOn, fastOn, thoughtToggleOn } = + useRunConfigFace(faceProps); + const hasToggle = planOn || fastOn || thoughtToggleOn; // The indicator hides itself for default/unknown modes; mirror that here so // the separator dot never renders next to nothing. const modeVisible = classifyPermissionModeFace(modeId).kind !== 'hidden'; @@ -204,6 +209,13 @@ export function MobileRunConfigButton({ aria-hidden="true" /> ) : null} + {thoughtToggleOn ? ( +