Choose non-fast variants for ACP models (#1952)

* fix(providers): expose ACP fast-mode selection

Provider wrappers now forward draft feature lookups to inner ACP clients, and ACP adapters can add initialize metadata for model parameterization. Fast-mode config options flow through the existing provider feature path.

* test(providers): cover wrapped ACP command lookup

* fix(providers): submit Cursor non-fast defaults

* fix(app): preserve parameterized model preferences

* fix(providers): preserve Cursor ACP feature params

* fix(providers): keep Cursor legacy model fallbacks

* fix(providers): preserve Cursor model parameter switches

* refactor(providers): simplify ACP model feature negotiation
This commit is contained in:
Mohamed Boudra
2026-07-10 05:16:17 +08:00
committed by GitHub
parent 6af1379357
commit b2c6476817
6 changed files with 212 additions and 12 deletions
@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import type { import type {
AgentClient, AgentClient,
AgentFeature,
AgentModelDefinition, AgentModelDefinition,
AgentMode, AgentMode,
AgentSessionConfig,
ProviderCatalog, ProviderCatalog,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
@@ -41,6 +43,7 @@ const mockState = vi.hoisted(() => {
}, },
isCommandAvailable: vi.fn(async (_command: string) => false), isCommandAvailable: vi.fn(async (_command: string) => false),
runtimeModels: new Map<string, AgentModelDefinition[]>(), runtimeModels: new Map<string, AgentModelDefinition[]>(),
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
reset() { reset() {
this.constructorArgs.claude = []; this.constructorArgs.claude = [];
this.constructorArgs.codex = []; this.constructorArgs.codex = [];
@@ -52,6 +55,7 @@ const mockState = vi.hoisted(() => {
this.isCommandAvailable.mockReset(); this.isCommandAvailable.mockReset();
this.isCommandAvailable.mockImplementation(async (_command: string) => false); this.isCommandAvailable.mockImplementation(async (_command: string) => false);
this.runtimeModels.clear(); this.runtimeModels.clear();
this.cursorListFeaturesConfigs = [];
}, },
}; };
}); });
@@ -378,6 +382,19 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
return true; return true;
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
mockState.cursorListFeaturesConfigs.push(config);
return [
{
type: "select",
id: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
},
];
}
}, },
})); }));
@@ -697,6 +714,41 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
expect(mockState.constructorArgs.genericAcp).toEqual([]); expect(mockState.constructorArgs.genericAcp).toEqual([]);
}); });
test("wrapped cursor client lists ACP features through the inner provider", async () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
cursor: {
extends: "acp",
label: "Cursor",
command: ["cursor-agent", "acp"],
},
},
});
const client = registry.cursor.createClient(logger);
await expect(
client.listFeatures?.({
provider: "cursor",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "select",
id: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
},
]);
expect(mockState.cursorListFeaturesConfigs).toEqual([
{
provider: "acp",
cwd: "/tmp/cursor",
},
]);
});
test("traecli provider extending acp uses TraeACPAgentClient", () => { test("traecli provider extending acp uses TraeACPAgentClient", () => {
const registry = buildProviderRegistry(logger, { const registry = buildProviderRegistry(logger, {
providerOverrides: { providerOverrides: {
@@ -398,6 +398,7 @@ function wrapClientProvider(
): AgentClient { ): AgentClient {
const listImportableSessions = inner.listImportableSessions?.bind(inner); const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner); const importSession = inner.importSession?.bind(inner);
const listFeatures = inner.listFeatures?.bind(inner);
return { return {
provider, provider,
@@ -441,6 +442,9 @@ function wrapClientProvider(
}, },
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listFeatures: listFeatures
? async (config) => await listFeatures({ ...config, provider: inner.provider })
: undefined,
listImportableSessions: listImportableSessions listImportableSessions: listImportableSessions
? async (options) => await listImportableSessions(options) ? async (options) => await listImportableSessions(options)
: undefined, : undefined,
@@ -229,7 +229,7 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsRewindBoth: false, supportsRewindBoth: false,
}; };
const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
fs: { fs: {
readTextFile: true, readTextFile: true,
writeTextFile: true, writeTextFile: true,
@@ -237,6 +237,18 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true, terminal: true,
}; };
export type ACPClientCapabilityMeta = Record<string, unknown>;
export function buildACPClientCapabilities(meta?: ACPClientCapabilityMeta): ACPClientCapabilities {
if (!meta || Object.keys(meta).length === 0) {
return BASE_ACP_CLIENT_CAPABILITIES;
}
return {
...BASE_ACP_CLIENT_CAPABILITIES,
_meta: meta,
};
}
// Suppress interactive auth side-effects (e.g. Gemini CLI opening a Google // Suppress interactive auth side-effects (e.g. Gemini CLI opening a Google
// sign-in URL in the browser) when probing an ACP agent for models/modes. // sign-in URL in the browser) when probing an ACP agent for models/modes.
// NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it. // NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it.
@@ -359,6 +371,7 @@ interface ACPAgentClientOptions {
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
configFeatureOptions?: ACPConfigFeatureOption[]; configFeatureOptions?: ACPConfigFeatureOption[];
clientCapabilityMeta?: ACPClientCapabilityMeta;
modeIdTransformer?: (modeId: string) => string | null; modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: ( providerModeWriter?: (
@@ -387,6 +400,7 @@ interface ACPAgentSessionOptions {
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
configFeatureOptions?: ACPConfigFeatureOption[]; configFeatureOptions?: ACPConfigFeatureOption[];
clientCapabilityMeta?: ACPClientCapabilityMeta;
modeIdTransformer?: (modeId: string) => string | null; modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: ( providerModeWriter?: (
@@ -486,7 +500,7 @@ interface ConfigOptionSelector {
export interface ACPConfigFeatureOption { export interface ACPConfigFeatureOption {
id: string; id: string;
configId: string; configId: string;
category: string; category?: string;
label: string; label: string;
description?: string; description?: string;
tooltip?: string; tooltip?: string;
@@ -694,6 +708,7 @@ export class ACPAgentClient implements AgentClient {
configOptions: SessionConfigOption[], configOptions: SessionConfigOption[],
) => SessionConfigOption[]; ) => SessionConfigOption[];
private readonly configFeatureOptions: ACPConfigFeatureOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[];
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: ( private readonly providerModeWriter?: (
@@ -727,6 +742,7 @@ export class ACPAgentClient implements AgentClient {
this.sessionResponseTransformer = options.sessionResponseTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer; this.configOptionsTransformer = options.configOptionsTransformer;
this.configFeatureOptions = options.configFeatureOptions ?? []; this.configFeatureOptions = options.configFeatureOptions ?? [];
this.clientCapabilityMeta = options.clientCapabilityMeta;
this.modeIdTransformer = options.modeIdTransformer; this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter; this.providerModeWriter = options.providerModeWriter;
@@ -754,6 +770,7 @@ export class ACPAgentClient implements AgentClient {
sessionResponseTransformer: this.sessionResponseTransformer, sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer, configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions, configFeatureOptions: this.configFeatureOptions,
clientCapabilityMeta: this.clientCapabilityMeta,
modeIdTransformer: this.modeIdTransformer, modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter, providerModeWriter: this.providerModeWriter,
@@ -802,6 +819,7 @@ export class ACPAgentClient implements AgentClient {
sessionResponseTransformer: this.sessionResponseTransformer, sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer, configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions, configFeatureOptions: this.configFeatureOptions,
clientCapabilityMeta: this.clientCapabilityMeta,
modeIdTransformer: this.modeIdTransformer, modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter, providerModeWriter: this.providerModeWriter,
@@ -1039,7 +1057,7 @@ export class ACPAgentClient implements AgentClient {
Promise.race([ Promise.race([
transport.connection.initialize({ transport.connection.initialize({
protocolVersion: PROTOCOL_VERSION, protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES, clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
clientInfo: { name: "Paseo", version: "dev" }, clientInfo: { name: "Paseo", version: "dev" },
}), }),
transport.spawnError, transport.spawnError,
@@ -1251,6 +1269,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
configOptions: SessionConfigOption[], configOptions: SessionConfigOption[],
) => SessionConfigOption[]; ) => SessionConfigOption[];
private readonly configFeatureOptions: ACPConfigFeatureOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[];
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: ( private readonly providerModeWriter?: (
@@ -1315,6 +1334,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.sessionResponseTransformer = options.sessionResponseTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer; this.configOptionsTransformer = options.configOptionsTransformer;
this.configFeatureOptions = options.configFeatureOptions ?? []; this.configFeatureOptions = options.configFeatureOptions ?? [];
this.clientCapabilityMeta = options.clientCapabilityMeta;
this.modeIdTransformer = options.modeIdTransformer; this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter; this.providerModeWriter = options.providerModeWriter;
@@ -1890,16 +1910,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}: { }: {
response: { configOptions: SessionConfigOption[] }; response: { configOptions: SessionConfigOption[] };
configId: string; configId: string;
category: string; category?: string;
requestedValue: string; requestedValue: string;
label: string; label: string;
}): string { }): string {
this.configOptions = this.transformConfigOptions(response.configOptions); this.configOptions = this.transformConfigOptions(response.configOptions);
const responseOption = findSelectConfigOption({ const responseOption =
configOptions: this.configOptions, category === undefined
category, ? findSelectConfigOptionById({ configOptions: this.configOptions, id: configId })
id: configId, : findSelectConfigOption({
}); configOptions: this.configOptions,
category,
id: configId,
});
if (responseOption?.currentValue != null) { if (responseOption?.currentValue != null) {
return responseOption.currentValue; return responseOption.currentValue;
} }
@@ -2297,7 +2320,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const initialize = await this.runACPRequest(() => const initialize = await this.runACPRequest(() =>
connection.initialize({ connection.initialize({
protocolVersion: PROTOCOL_VERSION, protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES, clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta),
clientInfo: { name: "Paseo", version: "dev" }, clientInfo: { name: "Paseo", version: "dev" },
}), }),
); );
@@ -2743,6 +2766,19 @@ function findSelectConfigOption({
return option ?? null; return option ?? null;
} }
function findSelectConfigOptionById({
configOptions,
id,
}: {
configOptions: SessionConfigOption[] | null | undefined;
id: string;
}): SelectConfigOption | null {
const option = configOptions?.find(
(entry): entry is SelectConfigOption => entry.type === "select" && entry.id === id,
);
return option ?? null;
}
function findSelectConfigFeatureOption( function findSelectConfigFeatureOption(
configOptions: SessionConfigOption[] | null | undefined, configOptions: SessionConfigOption[] | null | undefined,
featureOption: ACPConfigFeatureOption, featureOption: ACPConfigFeatureOption,
@@ -2751,7 +2787,7 @@ function findSelectConfigFeatureOption(
(entry): entry is SelectConfigOption => (entry): entry is SelectConfigOption =>
entry.type === "select" && entry.type === "select" &&
entry.id === featureOption.configId && entry.id === featureOption.configId &&
entry.category === featureOption.category, (featureOption.category === undefined || entry.category === featureOption.category),
); );
return option ?? null; return option ?? null;
} }
@@ -1,10 +1,22 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js"; import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js";
import { CursorACPAgentClient } from "./cursor-acp-agent.js"; import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
describe("CursorACPAgentClient model discovery", () => { describe("CursorACPAgentClient model discovery", () => {
function fastConfigOption(currentValue: "false" | "true") {
return {
id: "fast",
name: "Fast",
type: "select" as const,
currentValue,
options: [
{ value: "false", name: "Off" },
{ value: "true", name: "Fast" },
],
};
}
class TestCursorACPAgentClient extends CursorACPAgentClient { class TestCursorACPAgentClient extends CursorACPAgentClient {
constructor(response: SessionStateResponse) { constructor(response: SessionStateResponse) {
super({ super({
@@ -77,4 +89,79 @@ describe("CursorACPAgentClient model discovery", () => {
modes: [], modes: [],
}); });
}); });
test("keeps modern Cursor models as plain ACP ids", async () => {
const client = new TestCursorACPAgentClient({
sessionId: "session-1",
models: {
currentModelId: "composer-2.5",
availableModels: [
{
modelId: "composer-2.5",
name: "Composer 2.5",
description: null,
},
],
},
configOptions: [fastConfigOption("false")],
});
await expect(
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/cursor", force: false }),
).resolves.toEqual({
models: [
{
provider: "acp",
id: "composer-2.5",
label: "Composer 2.5",
description: undefined,
isDefault: true,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
},
],
modes: [],
});
});
test("exposes Cursor fast mode through provider features", async () => {
const client = new TestCursorACPAgentClient({
sessionId: "session-1",
models: null,
configOptions: [fastConfigOption("false")],
});
await expect(
client.listFeatures({
provider: "acp",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "select",
id: CURSOR_FAST_FEATURE_OPTION.id,
label: "Fast",
description: "Cursor fast mode",
tooltip: "Select Cursor fast mode",
icon: "zap",
value: "false",
options: [
{
id: "false",
label: "Off",
isDefault: true,
description: undefined,
metadata: undefined,
},
{
id: "true",
label: "Fast",
isDefault: false,
description: undefined,
metadata: undefined,
},
],
},
]);
});
}); });
@@ -1,5 +1,6 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { ACPConfigFeatureOption } from "./acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js";
interface CursorACPAgentClientOptions { interface CursorACPAgentClientOptions {
@@ -12,6 +13,18 @@ interface CursorACPAgentClientOptions {
} }
const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
const CURSOR_CLIENT_CAPABILITY_META = {
parameterizedModelPicker: true,
};
export const CURSOR_FAST_FEATURE_OPTION: ACPConfigFeatureOption = {
id: "fast",
configId: "fast",
label: "Fast",
description: "Cursor fast mode",
tooltip: "Select Cursor fast mode",
icon: "zap",
};
export class CursorACPAgentClient extends GenericACPAgentClient { export class CursorACPAgentClient extends GenericACPAgentClient {
constructor(options: CursorACPAgentClientOptions) { constructor(options: CursorACPAgentClientOptions) {
@@ -25,6 +38,8 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
// cursor-agent publishes slash commands asynchronously via available_commands_update. // cursor-agent publishes slash commands asynchronously via available_commands_update.
waitForInitialCommands: true, waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
clientCapabilityMeta: CURSOR_CLIENT_CAPABILITY_META,
configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION],
}); });
} }
} }
@@ -5,6 +5,8 @@ import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import { import {
ACPAgentClient, ACPAgentClient,
type ACPClientCapabilityMeta,
type ACPConfigFeatureOption,
DEFAULT_ACP_CAPABILITIES, DEFAULT_ACP_CAPABILITIES,
type ACPExtensionCommandsParser, type ACPExtensionCommandsParser,
} from "./acp-agent.js"; } from "./acp-agent.js";
@@ -33,6 +35,8 @@ interface GenericACPAgentClientOptions {
waitForInitialCommands?: boolean; waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number; initialCommandsWaitTimeoutMs?: number;
diagnosticPhaseTimeoutMs?: number; diagnosticPhaseTimeoutMs?: number;
clientCapabilityMeta?: ACPClientCapabilityMeta;
configFeatureOptions?: ACPConfigFeatureOption[];
extensionCommandsParser?: ACPExtensionCommandsParser; extensionCommandsParser?: ACPExtensionCommandsParser;
} }
@@ -53,6 +57,8 @@ export class GenericACPAgentClient extends ACPAgentClient {
capabilities: buildGenericACPCapabilities(options), capabilities: buildGenericACPCapabilities(options),
waitForInitialCommands: options.waitForInitialCommands, waitForInitialCommands: options.waitForInitialCommands,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
clientCapabilityMeta: options.clientCapabilityMeta,
configFeatureOptions: options.configFeatureOptions,
extensionCommandsParser: options.extensionCommandsParser, extensionCommandsParser: options.extensionCommandsParser,
}); });