Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions apps/cli/src/agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ 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.
- `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 +240,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);
});
});
16 changes: 12 additions & 4 deletions apps/cli/src/agent/acp-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type AcpConfigOptionSummary,
type AgentConfigCliType,
type BuiltinRuntimeOverrides,
type CustomAcpLaunchSpec,
Expand All @@ -13,6 +14,7 @@ import {
normalizeAcpSessionCapabilities,
type AcpCapabilitiesResult,
} from '@/agent/acp-capability-normalization';
import { fetchCursorModelCatalog, isRegistryCursorAgent } from '@/agent/cursor-acp';

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate existing caches before relying on the model catalog

When an existing registry Cursor configuration upgrades, its persisted capability entry still has the current ACP_CAPABILITY_CACHE_VERSION (6) and the same registry sourceVersion, so the selector accepts that entry even though it lacks this newly added field. Because configOptionsByModel is populated only by an explicit capability refresh and ordinary sessions never fetch it, existing users remain stuck with the probe-time snapshot behavior until they manually test/refresh the provider. Treat Cursor entries without the catalog as stale or bump the capability-cache version when adding this probe-derived field.

AGENTS.md reference: apps/cli/src/agent/AGENTS.md:L243-L245

Useful? React with 👍 / 👎.

};

/**
Expand Down Expand Up @@ -88,12 +91,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 @@ -77,6 +77,7 @@ import {
parseLodyExtensionMessage,
parseRateLimitsSnapshot,
} from './lody-acp-extension';
import { isRegistryCursorAgent } from './cursor-acp';

/**
* Checks if an error is a transport-related error that may be transient.
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