From b2c6476817533e6dcbc3da83ce11e09b320a2746 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Jul 2026 23:16:17 +0200 Subject: [PATCH] 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 --- .../server/agent/provider-registry.test.ts | 52 +++++++++++ .../src/server/agent/provider-registry.ts | 4 + .../src/server/agent/providers/acp-agent.ts | 58 +++++++++--- .../agent/providers/cursor-acp-agent.test.ts | 89 ++++++++++++++++++- .../agent/providers/cursor-acp-agent.ts | 15 ++++ .../agent/providers/generic-acp-agent.ts | 6 ++ 6 files changed, 212 insertions(+), 12 deletions(-) diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 877680c9e..dd986cb9a 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -3,8 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentClient, + AgentFeature, AgentModelDefinition, AgentMode, + AgentSessionConfig, ProviderCatalog, } from "./agent-sdk-types.js"; @@ -41,6 +43,7 @@ const mockState = vi.hoisted(() => { }, isCommandAvailable: vi.fn(async (_command: string) => false), runtimeModels: new Map(), + cursorListFeaturesConfigs: [] as AgentSessionConfig[], reset() { this.constructorArgs.claude = []; this.constructorArgs.codex = []; @@ -52,6 +55,7 @@ const mockState = vi.hoisted(() => { this.isCommandAvailable.mockReset(); this.isCommandAvailable.mockImplementation(async (_command: string) => false); this.runtimeModels.clear(); + this.cursorListFeaturesConfigs = []; }, }; }); @@ -378,6 +382,19 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ async isAvailable(): Promise { return true; } + + async listFeatures(config: AgentSessionConfig): Promise { + 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([]); }); +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", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index a582e1625..43d8d1048 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -398,6 +398,7 @@ function wrapClientProvider( ): AgentClient { const listImportableSessions = inner.listImportableSessions?.bind(inner); const importSession = inner.importSession?.bind(inner); + const listFeatures = inner.listFeatures?.bind(inner); return { provider, @@ -441,6 +442,9 @@ function wrapClientProvider( }, resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), + listFeatures: listFeatures + ? async (config) => await listFeatures({ ...config, provider: inner.provider }) + : undefined, listImportableSessions: listImportableSessions ? async (options) => await listImportableSessions(options) : undefined, diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index a4f459c7c..5e7e3c99e 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -229,7 +229,7 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { supportsRewindBoth: false, }; -const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { +const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { fs: { readTextFile: true, writeTextFile: true, @@ -237,6 +237,18 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { terminal: true, }; +export type ACPClientCapabilityMeta = Record; + +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 // 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. @@ -359,6 +371,7 @@ interface ACPAgentClientOptions { sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; + clientCapabilityMeta?: ACPClientCapabilityMeta; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; providerModeWriter?: ( @@ -387,6 +400,7 @@ interface ACPAgentSessionOptions { sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; + clientCapabilityMeta?: ACPClientCapabilityMeta; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; providerModeWriter?: ( @@ -486,7 +500,7 @@ interface ConfigOptionSelector { export interface ACPConfigFeatureOption { id: string; configId: string; - category: string; + category?: string; label: string; description?: string; tooltip?: string; @@ -694,6 +708,7 @@ export class ACPAgentClient implements AgentClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly providerModeWriter?: ( @@ -727,6 +742,7 @@ export class ACPAgentClient implements AgentClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.providerModeWriter = options.providerModeWriter; @@ -754,6 +770,7 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, + clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, @@ -802,6 +819,7 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, + clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, @@ -1039,7 +1057,7 @@ export class ACPAgentClient implements AgentClient { Promise.race([ transport.connection.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: ACP_CLIENT_CAPABILITIES, + clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta), clientInfo: { name: "Paseo", version: "dev" }, }), transport.spawnError, @@ -1251,6 +1269,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly providerModeWriter?: ( @@ -1315,6 +1334,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.providerModeWriter = options.providerModeWriter; @@ -1890,16 +1910,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { }: { response: { configOptions: SessionConfigOption[] }; configId: string; - category: string; + category?: string; requestedValue: string; label: string; }): string { this.configOptions = this.transformConfigOptions(response.configOptions); - const responseOption = findSelectConfigOption({ - configOptions: this.configOptions, - category, - id: configId, - }); + const responseOption = + category === undefined + ? findSelectConfigOptionById({ configOptions: this.configOptions, id: configId }) + : findSelectConfigOption({ + configOptions: this.configOptions, + category, + id: configId, + }); if (responseOption?.currentValue != null) { return responseOption.currentValue; } @@ -2297,7 +2320,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const initialize = await this.runACPRequest(() => connection.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: ACP_CLIENT_CAPABILITIES, + clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta), clientInfo: { name: "Paseo", version: "dev" }, }), ); @@ -2743,6 +2766,19 @@ function findSelectConfigOption({ 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( configOptions: SessionConfigOption[] | null | undefined, featureOption: ACPConfigFeatureOption, @@ -2751,7 +2787,7 @@ function findSelectConfigFeatureOption( (entry): entry is SelectConfigOption => entry.type === "select" && entry.id === featureOption.configId && - entry.category === featureOption.category, + (featureOption.category === undefined || entry.category === featureOption.category), ); return option ?? null; } diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index c6bea204d..1064de79f 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -1,10 +1,22 @@ import { describe, expect, test, vi } from "vitest"; 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"; 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 { constructor(response: SessionStateResponse) { super({ @@ -77,4 +89,79 @@ describe("CursorACPAgentClient model discovery", () => { 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, + }, + ], + }, + ]); + }); }); diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index b72ca5f5f..6dce76c97 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -1,5 +1,6 @@ import type { Logger } from "pino"; +import type { ACPConfigFeatureOption } from "./acp-agent.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; interface CursorACPAgentClientOptions { @@ -12,6 +13,18 @@ interface CursorACPAgentClientOptions { } 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 { constructor(options: CursorACPAgentClientOptions) { @@ -25,6 +38,8 @@ export class CursorACPAgentClient extends GenericACPAgentClient { // cursor-agent publishes slash commands asynchronously via available_commands_update. waitForInitialCommands: true, initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, + clientCapabilityMeta: CURSOR_CLIENT_CAPABILITY_META, + configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION], }); } } diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index c1da53e96..c1a0419b2 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -5,6 +5,8 @@ import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, + type ACPClientCapabilityMeta, + type ACPConfigFeatureOption, DEFAULT_ACP_CAPABILITIES, type ACPExtensionCommandsParser, } from "./acp-agent.js"; @@ -33,6 +35,8 @@ interface GenericACPAgentClientOptions { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; diagnosticPhaseTimeoutMs?: number; + clientCapabilityMeta?: ACPClientCapabilityMeta; + configFeatureOptions?: ACPConfigFeatureOption[]; extensionCommandsParser?: ACPExtensionCommandsParser; } @@ -53,6 +57,8 @@ export class GenericACPAgentClient extends ACPAgentClient { capabilities: buildGenericACPCapabilities(options), waitForInitialCommands: options.waitForInitialCommands, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, + clientCapabilityMeta: options.clientCapabilityMeta, + configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, });