diff --git a/packages/agent-providers/src/catalog.ts b/packages/agent-providers/src/catalog.ts index 117600f523..80e46095ae 100644 --- a/packages/agent-providers/src/catalog.ts +++ b/packages/agent-providers/src/catalog.ts @@ -147,9 +147,8 @@ const ACP_COMPOSER_ACTIONS: ProviderComposerAction[] = [ // Fork support is negotiated with each agent before the bridge sends the // unstable ACP session/fork request; agents that do not advertise it fail the // fork without falling back to a fresh session. -// Cursor exposes a `-fast` service tail per model; the bridge resolves it from -// the serviceTier (the "Fast mode" toggle), so service tier is supported here -// rather than fanning fast variants out as separate model-list entries. +// Cursor exposes Fast mode as an ACP config option, so it supports the shared +// service tier without separate model entries. const ACP_CAPABILITIES: ProviderCapabilities = { supportsArchive: false, supportsRename: false, diff --git a/packages/agent-runtime/src/acp/adapter.test.ts b/packages/agent-runtime/src/acp/adapter.test.ts index 81ffcd5c49..b0f6295862 100644 --- a/packages/agent-runtime/src/acp/adapter.test.ts +++ b/packages/agent-runtime/src/acp/adapter.test.ts @@ -44,10 +44,21 @@ function createCompactingAdapter(): AcpProviderAdapter { }); } -const CURSOR_LIST_COMMAND = { - command: "cursor-agent", - args: ["--list-models"], -}; +function createModelCliAdapter(): AcpProviderAdapter { + return createAcpProviderAdapter({ + profile: { + providerId: "acp-custom", + displayName: "Custom ACP", + agentCommand: { command: "custom-acp", args: ["serve"] }, + modelCli: { + listArgs: ["models", "list"], + selectFlag: "--model", + primaryModels: [], + }, + }, + additionalWorkspaceWriteRoots: [], + }); +} const THREAD_CONTEXT = { threadId: "thread-1" }; @@ -161,6 +172,7 @@ describe("acp adapter command plans", () => { workspaceWriteRoots: ["/workspace", "/extra-root"], envVars: { BB_THREAD_ID: "thread-1" }, instructions: "Stay focused.", + parameterizedModelPicker: true, }, }); }); @@ -513,18 +525,17 @@ describe("acp compaction events", () => { }); }); -describe("acp adapter model cli", () => { - it("requests the profile's model list command with its primary families", () => { - const plan = createAdapter().buildCommandPlan({ type: "model/list" }); - expect(plan).toMatchObject({ +describe("acp adapter models", () => { + it("discovers Cursor models from its ACP session", () => { + expect(createAdapter().buildCommandPlan({ type: "model/list" })).toEqual({ kind: "request", method: "model/list", - params: { listCommand: CURSOR_LIST_COMMAND }, + params: { + agent: { command: "cursor-agent", args: ["acp"] }, + primaryModels: ["grok-4.6", "grok-4.5"], + parameterizedModelPicker: true, + }, }); - const params = (plan as { params: Record }).params; - const primaryModels = params.primaryModels as string[]; - expect(primaryModels).toContain("auto"); - expect(primaryModels.length).toBeGreaterThan(1); }); it("requests ACP-native session discovery when the profile has no model CLI", () => { @@ -554,15 +565,16 @@ describe("acp adapter model cli", () => { }); }); - it("forwards the session model and reasoning level for bridge resolution", () => { + it("forwards Cursor's parameterized ACP model selection", () => { const plan = createAdapter().buildCommandPlan({ type: "thread/start", threadId: "thread-1", cwd: "/workspace", options: { ...fullProviderExecutionContext, - model: "gpt-5.3-codex", + model: "grok-4.6", reasoningLevel: "high", + serviceTier: "fast", }, instructionMode: "append", }); @@ -570,11 +582,11 @@ describe("acp adapter model cli", () => { params: { agent: { command: "cursor-agent", args: ["acp"] }, modelSelection: { - listCommand: CURSOR_LIST_COMMAND, - selectFlag: "--model", - model: "gpt-5.3-codex", + modelId: "grok-4.6", reasoningLevel: "high", + serviceTier: "fast", }, + parameterizedModelPicker: true, }, }); }); @@ -651,7 +663,7 @@ describe("acp adapter model cli", () => { }); expect(plan).toMatchObject({ params: { - modelSelection: { model: "gpt-5.3-codex" }, + modelSelection: { modelId: "gpt-5.3-codex" }, }, }); const params = (plan as { params: Record }).params; @@ -659,8 +671,8 @@ describe("acp adapter model cli", () => { expect("reasoningLevel" in selection).toBe(false); }); - it("forwards Fast mode as the model selection service tier", () => { - const plan = createAdapter().buildCommandPlan({ + it("forwards Fast mode to an agent with launch-time model selection", () => { + const plan = createModelCliAdapter().buildCommandPlan({ type: "thread/start", threadId: "thread-1", cwd: "/workspace", @@ -678,8 +690,8 @@ describe("acp adapter model cli", () => { }); }); - it("omits a default service tier from the model selection", () => { - const plan = createAdapter().buildCommandPlan({ + it("omits a default service tier from launch-time model selection", () => { + const plan = createModelCliAdapter().buildCommandPlan({ type: "thread/start", threadId: "thread-1", cwd: "/workspace", diff --git a/packages/agent-runtime/src/acp/adapter.ts b/packages/agent-runtime/src/acp/adapter.ts index 5894fbd4de..fff273c910 100644 --- a/packages/agent-runtime/src/acp/adapter.ts +++ b/packages/agent-runtime/src/acp/adapter.ts @@ -561,6 +561,12 @@ export function createAcpProviderAdapter( : { permissionCli: profile.permissionCli }; } + function buildParameterizedModelPickerParam(): Record { + return profile.parameterizedModelPicker === true + ? { parameterizedModelPicker: true } + : {}; + } + const turnState = createProviderTurnStateRegistry({ createState: () => ({ assistantMessageCounter: 0, @@ -1310,6 +1316,7 @@ export function createAcpProviderAdapter( ...buildModelSelectionParam(command.options), ...buildReasoningCliParam(), ...buildNativeReasoningParam(), + ...buildParameterizedModelPickerParam(), ...buildPermissionCliParam(), ...(profile.reasoningCli !== undefined && command.options.reasoningLevel !== undefined @@ -1346,6 +1353,10 @@ export function createAcpProviderAdapter( ...(options.reasoningLevel !== undefined ? { reasoningLevel: options.reasoningLevel } : {}), + ...(profile.parameterizedModelPicker === true && + options.serviceTier !== undefined + ? { serviceTier: options.serviceTier } + : {}), }, }; } @@ -1402,7 +1413,12 @@ export function createAcpProviderAdapter( params: { ...(listCommand !== undefined ? { listCommand } : {}), ...(agent !== undefined ? { agent } : {}), - primaryModels: [...(profile.modelCli?.primaryModels ?? [])], + primaryModels: [ + ...(profile.primaryModels ?? + profile.modelCli?.primaryModels ?? + []), + ], + ...buildParameterizedModelPickerParam(), ...buildReasoningCliParam(), ...buildNativeReasoningParam(), }, diff --git a/packages/agent-runtime/src/acp/bridge-protocol.ts b/packages/agent-runtime/src/acp/bridge-protocol.ts index 210fa0a171..fd816de0a9 100644 --- a/packages/agent-runtime/src/acp/bridge-protocol.ts +++ b/packages/agent-runtime/src/acp/bridge-protocol.ts @@ -72,9 +72,12 @@ const acpBridgeModelListParamsSchema = z.object({ /** * Family ids served in the picker's default list; the rest become * selected-only "more models". No matches (or an empty list) serves - * everything as primary. + * everything as primary. ACP-native discovery probes these ids first for + * reasoning options. It still returns all ACP-native models as primary. */ primaryModels: z.array(z.string()).default([]), + /** Enables Cursor's separate model, reasoning, and Fast mode options. */ + parameterizedModelPicker: z.boolean().optional(), reasoningCli: acpBridgeReasoningCliSchema.optional(), nativeReasoning: acpBridgeNativeReasoningSchema.optional(), }); @@ -82,11 +85,10 @@ const acpBridgeModelListParamsSchema = z.object({ /** * Session-level model pin. CLI-style agents resolve (model, reasoningLevel, * serviceTier) to a raw model id and launch with ` `. - * ACP-native agents receive `{ modelId }` after `session/new` — via their - * "model"-category config option (`session/set_config_option`) when they - * advertise one, otherwise via legacy `session/set_model`; if they expose a - * `thought_level` config option, the bridge applies `reasoningLevel` via - * `session/set_config_option`. Absent when the thread has no model preference. + * ACP-native agents receive these selections after `session/new`. The bridge + * uses the model config option, or legacy `session/set_model` when absent. + * It uses `thought_level` for reasoning and `fast` for the service tier. + * The selection is absent when the thread has no model preference. */ const acpBridgeCliModelSelectionSchema = z.object({ listCommand: acpBridgeAgentCommandSchema, @@ -99,6 +101,7 @@ const acpBridgeCliModelSelectionSchema = z.object({ const acpBridgeNativeModelSelectionSchema = z.object({ modelId: z.string().min(1), reasoningLevel: reasoningLevelSchema.optional(), + serviceTier: serviceTierSchema.optional(), }); const acpBridgeModelSelectionSchema = z.union([ @@ -121,6 +124,7 @@ const acpBridgeSessionParamsSchema = z.object({ launchReasoningLevel: reasoningLevelSchema.optional(), reasoningCli: acpBridgeReasoningCliSchema.optional(), nativeReasoning: acpBridgeNativeReasoningSchema.optional(), + parameterizedModelPicker: z.boolean().optional(), /** * Launch-time permission flags for agents whose own prompt policy must be * selected by CLI args rather than by ACP permission responses. diff --git a/packages/agent-runtime/src/acp/bridge/bridge.test.ts b/packages/agent-runtime/src/acp/bridge/bridge.test.ts index 0767c0f0fa..99aab28376 100644 --- a/packages/agent-runtime/src/acp/bridge/bridge.test.ts +++ b/packages/agent-runtime/src/acp/bridge/bridge.test.ts @@ -99,7 +99,12 @@ interface StartThreadArgs { model: string; reasoningLevel?: ReasoningLevel; } - | { modelId: string; reasoningLevel?: ReasoningLevel }; + | { + modelId: string; + reasoningLevel?: ReasoningLevel; + serviceTier?: "default" | "fast"; + }; + parameterizedModelPicker?: boolean; launchReasoningLevel?: ReasoningLevel; reasoningCli?: { flag: string; @@ -144,6 +149,9 @@ async function startThread(args?: StartThreadArgs): Promise<{ ...(args?.nativeReasoning !== undefined ? { nativeReasoning: args.nativeReasoning } : {}), + ...(args?.parameterizedModelPicker === true + ? { parameterizedModelPicker: true } + : {}), ...(args?.permissionCli !== undefined ? { permissionCli: args.permissionCli } : {}), @@ -415,6 +423,37 @@ describe("acp bridge", () => { }); }); + it("advertises parameterized model selection and probes primary models first", async () => { + const initializeLog = join(workspaceDir, "discovery-initialize.jsonl"); + const selectionLog = join(workspaceDir, "primary-model-selections.txt"); + const modelListId = sendRequest("model/list", { + agent: { + command: process.execPath, + args: [FAKE_AGENT_PATH], + envVars: { + FAKE_ACP_INITIALIZE_LOG: initializeLog, + FAKE_ACP_MODEL_CONFIG: "1", + FAKE_ACP_SET_CONFIG_LOG: selectionLog, + FAKE_ACP_THOUGHT_LEVEL_CONFIG: "1", + }, + }, + primaryModels: ["fake/strong"], + parameterizedModelPicker: true, + }); + + await waitForResponse(modelListId); + await waitForFileWithRealTimer(initializeLog); + const initializeParams = JSON.parse( + readFileSync(initializeLog, "utf8").trim(), + ); + expect(initializeParams.clientCapabilities).toMatchObject({ + _meta: { parameterizedModelPicker: true }, + }); + expect(readFileSync(selectionLog, "utf8").split("\n")[0]).toBe( + "fake/strong", + ); + }); + it("discovers ACP-native models from session models state", async () => { const modelListId = sendRequest("model/list", { agent: { @@ -893,6 +932,26 @@ describe("acp bridge", () => { expect(agentMessageTexts()).toContain("selected-model:fake/strong"); }); + it("advertises parameterized model selection during a live session", async () => { + const initializeLog = join(workspaceDir, "session-initialize.jsonl"); + await startThread({ + envVars: { + FAKE_ACP_INITIALIZE_LOG: initializeLog, + FAKE_ACP_MODEL_CONFIG: "1", + }, + modelSelection: { modelId: "fake/strong" }, + parameterizedModelPicker: true, + }); + + await waitForFileWithRealTimer(initializeLog); + const initializeParams = JSON.parse( + readFileSync(initializeLog, "utf8").trim(), + ); + expect(initializeParams.clientCapabilities).toMatchObject({ + _meta: { parameterizedModelPicker: true }, + }); + }); + it("falls back to session/set_model when the model config option errors", async () => { const { providerThreadId } = await startThread({ envVars: { @@ -944,6 +1003,39 @@ describe("acp bridge", () => { expect(agentMessageTexts()).toContain("selected-effort:xhigh"); }); + it.each([ + { + serviceTier: "fast" as const, + initialFast: "false", + selectedFast: "true", + }, + { + serviceTier: "default" as const, + initialFast: "true", + selectedFast: "false", + }, + ])( + "maps the $serviceTier service tier to Fast mode $selectedFast", + async ({ serviceTier, initialFast, selectedFast }) => { + const { providerThreadId } = await startThread({ + envVars: { + FAKE_ACP_FAST_CONFIG: "1", + FAKE_ACP_INITIAL_FAST: initialFast, + FAKE_ACP_MODEL_CONFIG: "1", + }, + modelSelection: { modelId: "fake/strong", serviceTier }, + }); + + sendRequest("turn/start", { + threadId: providerThreadId, + input: [{ type: "text", text: "echo-selected-fast", mentions: [] }], + }); + await waitForTurnCompleted(); + + expect(agentMessageTexts()).toContain(`selected-fast:${selectedFast}`); + }, + ); + it("applies configured native reasoning when the ACP agent does not advertise thought_level", async () => { const { providerThreadId } = await startThread({ envVars: { diff --git a/packages/agent-runtime/src/acp/bridge/bridge.ts b/packages/agent-runtime/src/acp/bridge/bridge.ts index 3515b5ab12..e193063f81 100644 --- a/packages/agent-runtime/src/acp/bridge/bridge.ts +++ b/packages/agent-runtime/src/acp/bridge/bridge.ts @@ -646,6 +646,19 @@ async function authenticateAcpAgent(args: { }); } +function acpClientCapabilities( + parameterizedModelPicker?: boolean, + fsAccess = false, +) { + return { + fs: { readTextFile: fsAccess, writeTextFile: fsAccess }, + terminal: false, + ...(parameterizedModelPicker === true + ? { _meta: { parameterizedModelPicker: true } } + : {}), + }; +} + /** * Run the agent's model list command and build the variant catalog, cached * per list command for the bridge's lifetime (model/list refreshes it on the @@ -705,8 +718,14 @@ async function loadAgentModelCatalog( async function loadSessionDiscoveredModels( agent: AcpBridgeAgentCommand, + primaryModels: readonly string[], + parameterizedModelPicker?: boolean, ): Promise { - const key = JSON.stringify(agent); + const key = JSON.stringify({ + agent, + primaryModels, + parameterizedModelPicker, + }); if ( cachedSessionDiscoveredModels?.key === key && Date.now() - cachedSessionDiscoveredModels.fetchedAt < @@ -751,10 +770,7 @@ async function loadSessionDiscoveredModels( params: { protocolVersion: ACP_PROTOCOL_VERSION, clientInfo: { name: "bb", version: "1.0.0" }, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, + clientCapabilities: acpClientCapabilities(parameterizedModelPicker), }, resultSchema: acpInitializeResultSchema, }); @@ -796,6 +812,7 @@ async function loadSessionDiscoveredModels( connection, sessionId: newSession.sessionId, modelOption, + primaryModels, }); const models = reasoningByModel === null @@ -826,12 +843,30 @@ async function discoverAcpNativeReasoningByModel(args: { connection: AcpAgentConnection; sessionId: string; modelOption: AcpConfigOption | undefined; + primaryModels: readonly string[]; }): Promise | null> { const modelOptions = args.modelOption?.options ?? []; if (!args.modelOption || modelOptions.length === 0) { return null; } const modelOption = args.modelOption; + const modelByValue = new Map( + modelOptions.map((model) => [model.value, model] as const), + ); + const modelsToProbe: typeof modelOptions = []; + const addedModels = new Set(); + for (const value of args.primaryModels) { + const model = modelByValue.get(value); + if (model && !addedModels.has(model.value)) { + modelsToProbe.push(model); + addedModels.add(model.value); + } + } + for (const model of modelOptions) { + if (!addedModels.has(model.value)) { + modelsToProbe.push(model); + } + } // Each probe is one set_config_option round trip to the local agent, so // work is bounded by the time budget rather than a model-count cutoff @@ -852,7 +887,7 @@ async function discoverAcpNativeReasoningByModel(args: { try { return await Promise.race([ (async () => { - for (const model of modelOptions) { + for (const model of modelsToProbe) { const configState = await args.connection.request({ method: "session/set_config_option", params: { @@ -1057,6 +1092,12 @@ async function selectAcpNativeModel(args: { modelSelection: selection, nativeReasoning: args.nativeReasoning, }); + await selectAcpNativeServiceTier({ + connection: args.connection, + sessionId: args.sessionId, + configOptions, + modelSelection: selection, + }); } async function selectAcpNativeReasoning(args: { @@ -1101,6 +1142,41 @@ async function selectAcpNativeReasoning(args: { } } +async function selectAcpNativeServiceTier(args: { + connection: AcpAgentConnection; + sessionId: string; + configOptions: readonly AcpConfigOption[] | undefined; + modelSelection: Extract< + AcpBridgeThreadStartParams["modelSelection"], + { modelId: string } + >; +}): Promise { + const serviceTier = args.modelSelection.serviceTier; + if (serviceTier === undefined) { + return; + } + const fastOption = (args.configOptions ?? []).find( + (option) => option.id === "fast" && option.type === "select", + ); + const value = serviceTier === "fast" ? "true" : "false"; + if (!fastOption?.options?.some((option) => option.value === value)) { + return; + } + try { + await args.connection.request({ + method: "session/set_config_option", + params: { + sessionId: args.sessionId, + configId: fastOption.id, + value, + }, + resultSchema: acpConfigStateResultSchema, + }); + } catch { + // Unsupported or stale service tiers should leave the agent default intact. + } +} + // --------------------------------------------------------------------------- // Prompt content // --------------------------------------------------------------------------- @@ -1505,10 +1581,10 @@ async function startAgentSession( params: { protocolVersion: ACP_PROTOCOL_VERSION, clientInfo: { name: "bb", version: "1.0.0" }, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, - terminal: false, - }, + clientCapabilities: acpClientCapabilities( + params.parameterizedModelPicker, + true, + ), }, resultSchema: acpInitializeResultSchema, }); @@ -1938,7 +2014,11 @@ async function handleRequest( } const sessionDiscoveredModels = request.params.listCommand === undefined && request.params.agent - ? await loadSessionDiscoveredModels(request.params.agent) + ? await loadSessionDiscoveredModels( + request.params.agent, + request.params.primaryModels, + request.params.parameterizedModelPicker, + ) : null; if (sessionDiscoveredModels) { sendResult(request.id, { diff --git a/packages/agent-runtime/src/acp/bridge/fake-acp-agent.mjs b/packages/agent-runtime/src/acp/bridge/fake-acp-agent.mjs index 6d699c1e66..c46a571a25 100755 --- a/packages/agent-runtime/src/acp/bridge/fake-acp-agent.mjs +++ b/packages/agent-runtime/src/acp/bridge/fake-acp-agent.mjs @@ -21,6 +21,9 @@ * - FAKE_ACP_MODELS_FIELD=1 → advertise legacy ACP models state * - FAKE_ACP_THOUGHT_LEVEL_CONFIG=1 * → advertise per-model effort configOptions + * - FAKE_ACP_FAST_CONFIG=1 → advertise the Fast mode config option + * - FAKE_ACP_INITIAL_FAST → set the initial Fast mode value + * - FAKE_ACP_INITIALIZE_LOG → append initialize params as JSON * - FAKE_ACP_UNMAPPED_REASONING_CONFIG=1 * → advertise unmapped thought_level values * - FAKE_ACP_ACCEPT_NATIVE_REASONING=1 @@ -28,6 +31,7 @@ * advertising a thought_level config option * - FAKE_ACP_SET_CONFIG_MODEL_ERROR=1 * → fail session/set_config_option for model values + * - FAKE_ACP_SET_CONFIG_LOG → append selected model values * - FAKE_ACP_MODEL_COUNT= → pad the catalog to n reasoning-capable models * (exercises large-catalog reasoning discovery) * - FAKE_ACP_AUTH_METHODS → comma-separated auth method ids to advertise; @@ -53,6 +57,7 @@ const usageSessionId = process.env.FAKE_ACP_USAGE_SESSION_ID; const modelConfig = process.env.FAKE_ACP_MODEL_CONFIG === "1"; const modelsField = process.env.FAKE_ACP_MODELS_FIELD === "1"; const thoughtLevelConfig = process.env.FAKE_ACP_THOUGHT_LEVEL_CONFIG === "1"; +const fastConfig = process.env.FAKE_ACP_FAST_CONFIG === "1"; const unmappedReasoningConfig = process.env.FAKE_ACP_UNMAPPED_REASONING_CONFIG === "1"; const acceptNativeReasoning = @@ -73,6 +78,7 @@ let activePromptId = null; let nextAgentRequestId = 1000; let selectedModel = "fake/default"; let selectedEffort = "none"; +let selectedFast = process.env.FAKE_ACP_INITIAL_FAST ?? "false"; let authenticatedMethod = null; let activeSessionId = sessionId; const pendingClientRequests = new Map(); @@ -173,6 +179,17 @@ function configOptions() { options: fakeModels, }, effortOptionForModel(selectedModel), + ...(fastConfig + ? [ + { + id: "fast", + name: "Fast mode", + type: "select", + currentValue: selectedFast, + options: [{ value: "false" }, { value: "true" }], + }, + ] + : []), ].filter(Boolean); } @@ -313,6 +330,8 @@ async function handlePrompt(message) { notifyUpdate(messageChunk(`selected-model:${selectedModel}`)); } else if (text.includes("echo-selected-effort")) { notifyUpdate(messageChunk(`selected-effort:${selectedEffort}`)); + } else if (text.includes("echo-selected-fast")) { + notifyUpdate(messageChunk(`selected-fast:${selectedFast}`)); } else if (text.includes("echo-auth-method")) { notifyUpdate(messageChunk(`auth-method:${authenticatedMethod ?? "none"}`)); } else if (text.includes("echo-electron-run-as-node")) { @@ -355,6 +374,12 @@ async function handleMessage(message) { if (hangInitialize) { return; } + if (process.env.FAKE_ACP_INITIALIZE_LOG) { + appendFileSync( + process.env.FAKE_ACP_INITIALIZE_LOG, + `${JSON.stringify(message.params)}\n`, + ); + } send({ jsonrpc: "2.0", id: message.id, @@ -511,6 +536,9 @@ async function handleMessage(message) { }); return; } + if (process.env.FAKE_ACP_SET_CONFIG_LOG) { + appendFileSync(process.env.FAKE_ACP_SET_CONFIG_LOG, `${value}\n`); + } selectedModel = value; send({ jsonrpc: "2.0", id: message.id, result: configState() }); return; @@ -533,6 +561,19 @@ async function handleMessage(message) { send({ jsonrpc: "2.0", id: message.id, result: configState() }); return; } + if (configId === "fast") { + if (!fastConfig || (value !== "false" && value !== "true")) { + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32602, message: `fast mode not found: ${value}` }, + }); + return; + } + selectedFast = value; + send({ jsonrpc: "2.0", id: message.id, result: configState() }); + return; + } if (configId === "reasoning_effort" && acceptNativeReasoning) { if (typeof value !== "string") { send({ diff --git a/packages/agent-runtime/src/acp/profiles.ts b/packages/agent-runtime/src/acp/profiles.ts index ced6cd92e7..a390ab5b29 100644 --- a/packages/agent-runtime/src/acp/profiles.ts +++ b/packages/agent-runtime/src/acp/profiles.ts @@ -35,11 +35,14 @@ export interface AcpAgentProfile { reasoningCli?: AcpAgentReasoningCli; nativeReasoning?: AcpAgentNativeReasoning; permissionCli?: AcpAgentPermissionCli; + /** Enables an agent extension for separate model configuration options. */ + parameterizedModelPicker?: boolean; + /** Model ids to probe first during ACP-native reasoning discovery. */ + primaryModels?: string[]; } interface BuiltInAcpAgentProfile extends AcpAgentProfile { providerId: AcpAgentProviderId; - modelCli: AcpAgentModelCli; } export const ACP_AGENT_PROFILES: readonly BuiltInAcpAgentProfile[] = [ @@ -51,23 +54,10 @@ export const ACP_AGENT_PROFILES: readonly BuiltInAcpAgentProfile[] = [ // on PATH cannot silently replace Cursor and collapse model discovery to // the synthetic fallback. agentCommand: { command: "cursor-agent", args: ["acp"] }, - // Global flags must precede the `acp` subcommand, matching the documented - // `cursor-agent --api-key ... acp` form. - modelCli: { - listArgs: ["--list-models"], - selectFlag: "--model", - // Family ids (the default variant's raw id), not raw variant ids: the - // catalog folds effort and the `-fast` tail into one entry per family. - primaryModels: [ - "auto", - "cursor-grok-4.5-medium", - "gpt-5.6-sol-medium", - "claude-opus-5-thinking-medium", - "claude-fable-5-thinking-medium", - // Composer is one family now; its `-fast` twin is the Fast-mode tier. - "composer-2.5", - ], - }, + // Cursor exposes model effort and Fast mode as separate ACP config options + // only when the client advertises this Cursor extension capability. + parameterizedModelPicker: true, + primaryModels: ["grok-4.6", "grok-4.5"], }, ]; diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index ad3daeadb5..7aab483894 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -36,7 +36,7 @@ import { providerCliStatusResponseSchema, } from "./local.js"; -export const HOST_DAEMON_PROTOCOL_VERSION = 123 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 124 as const; export { BRANCH_LIST_LIMIT_MAX, diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 4040da4e93..8849c58526 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1060,6 +1060,9 @@ describe("host-daemon local schemas", () => { }); describe("host-daemon command schemas", () => { + // Version 124 advertises Cursor's parameterized model picker and sends its + // model, reasoning, and Fast mode as ACP config options. Older daemons use + // launch-only model variants that Cursor ACP ignores. // Version 123 adds required status-enrichment budgets and a required // diff-files truncation marker. Older daemons cannot safely enforce or // interpret the new bounded workspace response contract. @@ -1093,7 +1096,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(123); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(124); }); it("requires an explicit intent on a thread stop command", () => {