Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
14eb6db
feat(shared): resolve ACP config options per model
Tigatron Sep 3, 2026
042de03
feat(cli): cache Cursor per-model config options from capability probes
Tigatron Sep 3, 2026
5494231
fix(cli): switch the ACP model before applying per-model options
Tigatron Sep 3, 2026
3c26598
fix(shared): accept configOptionsByModel in the capability wire schema
Tigatron Sep 6, 2026
8210fab
fix(cli): invalidate pre-picker registry Cursor capability rows
Tigatron Sep 6, 2026
ecfd2d7
fix: validate saved role models against current capabilities
Tigatron Sep 6, 2026
c11eab0
feat(cli): observe the Cursor model catalog from created sessions
Tigatron Sep 6, 2026
e63f788
fix(components): settle a pending Role restore on explicit user selec…
Tigatron Sep 6, 2026
a40fc54
feat(shared): map MCP run config onto the target model's option catalog
Tigatron Sep 3, 2026
818d2fe
fix(cli): validate and inherit turn config options per target model
Tigatron Sep 3, 2026
29499b0
fix(cli): omit the per-model catalog from the refresh response capabi…
Tigatron Sep 6, 2026
7a78bac
fix(shared): negotiate the Cursor picker marker through protocolCapab…
Tigatron Sep 6, 2026
ed5eeff
docs(cli): record the Cursor catalog contracts in the condensed AGENT…
Tigatron Sep 6, 2026
83cdbaf
fix(acp): preserve current Cursor catalogs across dispatch and refresh
Tigatron Sep 6, 2026
2260008
fix(acp): reconcile Cursor capabilities with latest main
Tigatron Sep 7, 2026
3437137
chore: merge main into Cursor model catalog PR
Tigatron Sep 8, 2026
b8fc00a
fix(acp): preserve cache compatibility while merging main
Tigatron Sep 9, 2026
3dea518
fix(components): preserve user edits during pending role restore
Tigatron Sep 9, 2026
d5492c0
fix(components): reject role capabilities from replaced configs
Tigatron Sep 9, 2026
56c1509
fix(acp): bind role capabilities to owner source versions
Tigatron Sep 9, 2026
8d00bfd
test: deduplicate Cursor ACP coverage
Tigatron Sep 9, 2026
4349818
fix: preserve legacy Cursor refresh overlays
Tigatron Sep 9, 2026
cbbb432
fix(acp): narrow Cursor catalog scope and integrate main
Tigatron Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 2 additions & 8 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions apps/cli/src/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/cli/src/CLAUDE.md
23 changes: 23 additions & 0 deletions apps/cli/src/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions apps/cli/src/agent/acp-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -17,6 +18,14 @@ vi.mock('./acp-authentication', () => ({
probeBuiltinAuthentication: mocks.probeBuiltinAuthentication,
}));

vi.mock('./cursor-acp', async (importOriginal) => {
const actual = await importOriginal<typeof import('./cursor-acp')>();
return {
...actual,
fetchCursorModelCatalog: mocks.fetchCursorModelCatalog,
};
});

import { fetchAcpCapabilities } from './acp-capabilities';
import { AcpAuthenticationRequiredError } from './agent-client';

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
28 changes: 23 additions & 5 deletions apps/cli/src/agent/acp-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading