Skip to content
7 changes: 6 additions & 1 deletion apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,12 @@ Two things the dev build does deliberately, both load-bearing:
publish the legacy `model[effort]` list (Codex); effort is validated against the
TARGET model and the ids so validated come back as `validatedConfigIds`, which
`validateTurnConfigOptionValues(..., skipIds)` must skip (the probed model's list
would wrongly reject them). What cannot be checked offline is dispatched as
would wrongly reject them). When the cache carries `configOptionsByModel` (registry
Cursor), the semantic mapping, turn validation, and inherited-default filtering all
read the TARGET model's composed options through `resolveAcpConfigOptionsForModel`;
inherited create 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. What cannot be checked offline is dispatched as
requested. Runtime rejections remain in debug diagnostics; Codex/Claude mismatches
for model, reasoning effort, Fast, or Plan are not promoted to visible
`agent_warning` notices, while other rejected selections still are. Compatibility exception: Claude
Expand Down
23 changes: 23 additions & 0 deletions apps/cli/src/agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ arrive: context/message-flow.md "Upstream".
answer before giving up on the upstream turn's response: the Codex adapter drains
session notifications before refusing, so the turn's response routinely wins that
race and would otherwise mask the refusal.
Registry Cursor opts into cursor-agent's clean model ids via
`clientCapabilities._meta.parameterizedModelPicker` at initialize; the gate is
registry identity (`cliType: 'registry'` and `agentType: 'cursor'`), never a
same-named custom or builtin config. Downstream capability consumers stay
provider-neutral. The opt-in changes what the agent advertises, so
`getAcpCapabilitySourceVersion` appends
`CURSOR_PARAMETERIZED_MODEL_PICKER_SOURCE_VERSION_SUFFIX` to registry Cursor's
source version and `isAcpCapabilityCacheEntryCurrent` rejects a registry Cursor
row without it: rows probed before the opt-in (exploded variant ids, no catalog)
are never authoritative, and the first session or refresh rewrites them.
Predicate and suffix are one binding in `@lody/shared` `ai.ts`; never re-derive
either in the CLI.
- `acp-runner.ts` — process spawn/restart around the client. Spawn + initialize +
`newSession`/`loadSession` share `acp-session-start-gate.ts` (default 2,
`LODY_MAX_CONCURRENT_ACP_SESSION_STARTS`). Unbounded concurrent Codex starts
Expand Down Expand Up @@ -235,6 +247,17 @@ arrive: context/message-flow.md "Upstream".
non-blocking cache update before the first prompt. Machine Flock writes ignore
`fetchedAt` when comparing entries, so unchanged runtime capabilities do not
commit or sync.
Registry Cursor's per-model option catalog (`AcpCapabilityCacheEntry.configOptionsByModel`)
comes only from an explicit `machine/acp-capabilities-refresh` probe calling
`cursor/list_available_models` once after `session/new`; real sessions never fetch it.
JSON-RPC `-32601` means no catalog; any other failure fails the probe with
`[ACP_CAPABILITIES_INCOMPLETE]` so the settings test button can retry. Omitting
`configOptionsByModel` on a Machine Flock write preserves the stored catalog for the
same `sourceVersion`, and the unchanged-entry comparison includes it. Never enumerate
models through `session/set_config_option`: it rewrites the user's global Cursor config.
`resolveAcpConfigOptionsForModel` in `@lody/shared` is the one composition rule: an
option owned by any model's catalog entry is per-model, and `model`/`mode` options
always come from the snapshot.
- `login-shell-env.ts` — login-shell env capture for spawned agents.
- Builtin Claude owns session title generation through ACP
`session_info_update`; `AgentClient` forwards those titles and `MessageHandler`
Expand Down
51 changes: 51 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,45 @@ describe('fetchAcpCapabilities', () => {

expect(result.configOptions).toBeUndefined();
});

it('attaches the Cursor model catalog for a registry Cursor probe', async () => {
const configOptionsByModel = {
'model-full': [
{
id: 'thinking',
name: 'Thinking',
type: 'select' as const,
currentValue: 'true',
options: [],
},
],
};
mocks.fetchCursorModelCatalog.mockResolvedValue(configOptionsByModel);

const result = await fetchAcpCapabilities('registry', 'cursor', createSilentLogger());

expect(result.configOptionsByModel).toEqual(configOptionsByModel);
expect(mocks.fetchCursorModelCatalog).toHaveBeenCalledTimes(1);
});

it('does not fetch a model catalog for custom or builtin probes', async () => {
const customResult = await fetchAcpCapabilities('custom', 'cursor', createSilentLogger());
const builtinResult = await fetchAcpCapabilities('builtin', 'claude', createSilentLogger());

expect(customResult.configOptionsByModel).toBeUndefined();
expect(builtinResult.configOptionsByModel).toBeUndefined();
expect(mocks.fetchCursorModelCatalog).not.toHaveBeenCalled();
});

it('shuts down the temp agent when the Cursor catalog fetch is incomplete', async () => {
const incomplete = new Error(
'[ACP_CAPABILITIES_INCOMPLETE] cursor/list_available_models failed: boom'
);
mocks.fetchCursorModelCatalog.mockRejectedValue(incomplete);

await expect(fetchAcpCapabilities('registry', 'cursor', createSilentLogger())).rejects.toBe(
incomplete
);
expect(mocks.shutdownLocalAcpAgent).toHaveBeenCalledTimes(1);
});
});
17 changes: 13 additions & 4 deletions apps/cli/src/agent/acp-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
type AcpConfigOptionSummary,
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 +15,7 @@ import {
normalizeAcpSessionCapabilities,
type AcpCapabilitiesResult,
} from '@/agent/acp-capability-normalization';
import { fetchCursorModelCatalog } from '@/agent/cursor-acp';

export { normalizeConfigOptions } from '@/agent/acp-capability-normalization';
export type { AcpCapabilitiesResult } from '@/agent/acp-capability-normalization';
Expand All @@ -24,6 +27,7 @@ export type FetchAcpCapabilitiesOptions = {

export type FetchedAcpCapabilities = AcpCapabilitiesResult & {
capabilitySourceVersion?: string;
configOptionsByModel?: Record<string, AcpConfigOptionSummary[]>;
};

/**
Expand Down Expand Up @@ -88,12 +92,17 @@ export async function fetchAcpCapabilities(
});

try {
const normalized = normalizeAcpSessionCapabilities(sessionResponse, {
sessionFork: client.supportsSessionFork?.() === true,
acknowledgedSteer: client.supportsAcknowledgedSteer(),
});
const configOptionsByModel = isRegistryCursorAgent({ cliType, agentType })
? await fetchCursorModelCatalog({ client, signal: options.signal, logger })
: undefined;
return {
...normalizeAcpSessionCapabilities(sessionResponse, {
sessionFork: client.supportsSessionFork?.() === true,
acknowledgedSteer: client.supportsAcknowledgedSteer(),
}),
...normalized,
capabilitySourceVersion,
...(configOptionsByModel !== undefined ? { configOptionsByModel } : {}),
};
} finally {
await shutdownLocalAcpAgent({
Expand Down
95 changes: 95 additions & 0 deletions apps/cli/src/agent/agent-client-initialize.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
});
});
43 changes: 42 additions & 1 deletion apps/cli/src/agent/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
buildAskUserQuestionElicitationResponse,
formatMcpResolutionProblem,
getServerNow,
isRegistryCursorAgent,
} from '@lody/shared';
import { getLocalControlSocketPath } from '@lody/shared/node/local-ipc';
import { getLodyMcpHttpEndpoint } from '@/mcp/lody-mcp-http-server';
Expand Down Expand Up @@ -213,7 +214,7 @@ function isAcpInvalidRequestError(error: unknown): boolean {
);
}

function isAcpMethodNotFoundError(error: unknown): boolean {
export function isAcpMethodNotFoundError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
Expand Down Expand Up @@ -1384,6 +1385,40 @@ export class AgentClient implements acp.Client {
return {};
}

async requestExtMethod(
method: string,
params: Record<string, unknown> = {},
options: { signal?: AbortSignal } = {}
): Promise<Record<string, unknown>> {
const connection = this.connection;
if (!connection) {
throw new Error('ACP session is not connected');
}
options.signal?.throwIfAborted();
const request = connection.request<Record<string, unknown>, Record<string, unknown>>(
method,
params
);
const signal = options.signal;
if (!signal) {
return request;
}
let onAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_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<string, unknown>): Promise<void> {
try {
await this.handleExtensionMessage(method, params);
Expand Down Expand Up @@ -1750,6 +1785,12 @@ export class AgentClient implements acp.Client {
elicitation: {
form: {},
},
...(isRegistryCursorAgent({
cliType: this.options.agentConfig?.cliType,
agentType: this.options.agentConfig?.agentType,
})
? { _meta: { parameterizedModelPicker: true } }
: {}),
},
}),
startupAbort
Expand Down
Loading