From 9eb1ddf29da4469b89d919d82c4baeb20bd53c2b Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Wed, 13 May 2026 10:12:53 +0100 Subject: [PATCH] fix(cursor): discover models via Cursor ACP client Fixes #925 --- .../server/agent/provider-registry.test.ts | 86 ++++++++ .../src/server/agent/provider-registry.ts | 42 +++- .../agent/providers/cursor-acp-agent.test.ts | 193 ++++++++++++++++++ .../agent/providers/cursor-acp-agent.ts | 125 ++++++++++++ 4 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/server/agent/providers/cursor-acp-agent.test.ts create mode 100644 packages/server/src/server/agent/providers/cursor-acp-agent.ts diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 14db97fff..882c36ba7 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -13,6 +13,10 @@ const mockState = vi.hoisted(() => { claude: [] as ConstructorEntry[], codex: [] as ConstructorEntry[], copilot: [] as ConstructorEntry[], + cursor: [] as Array<{ + command: string[]; + env?: Record; + }>, pi: [] as ConstructorEntry[], genericAcp: [] as Array<{ command: string[]; @@ -27,6 +31,7 @@ const mockState = vi.hoisted(() => { this.constructorArgs.claude = []; this.constructorArgs.codex = []; this.constructorArgs.copilot = []; + this.constructorArgs.cursor = []; this.constructorArgs.pi = []; this.constructorArgs.genericAcp = []; this.isCommandAvailable.mockReset(); @@ -286,6 +291,55 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ }, })); +vi.mock("./providers/cursor-acp-agent.js", () => ({ + CursorACPAgentClient: class CursorACPAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; + readonly provider = "acp"; + readonly runtimeSettings?: unknown; + + constructor(options: { command: string[]; env?: Record }) { + this.runtimeSettings = { + command: { + mode: "replace", + argv: options.command, + }, + env: options.env, + }; + mockState.constructorArgs.cursor.push({ + command: options.command, + env: options.env, + }); + } + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async listModels(): Promise { + return mockState.runtimeModels.get(this.provider) ?? []; + } + + async listModes(): Promise<[]> { + return []; + } + + async isAvailable(): Promise { + return true; + } + }, +})); + import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry, @@ -410,6 +464,38 @@ test("new provider extending acp uses GenericACPAgentClient", () => { ]); }); +test("cursor provider extending acp uses CursorACPAgentClient", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + cursor: { + extends: "acp", + label: "Cursor", + command: ["cursor-agent", "acp"], + env: { + CURSOR_AGENT_LOG: "debug", + }, + }, + }, + }); + + expect(registry.cursor.createClient(logger).provider).toBe("cursor"); + expect(mockState.constructorArgs.cursor).toEqual([ + { + command: ["cursor-agent", "acp"], + env: { + CURSOR_AGENT_LOG: "debug", + }, + }, + { + command: ["cursor-agent", "acp"], + env: { + CURSOR_AGENT_LOG: "debug", + }, + }, + ]); + expect(mockState.constructorArgs.genericAcp).toEqual([]); +}); + test('extends: "acp" without command throws', () => { expect(() => buildProviderRegistry(logger, { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 21e71ea05..06e5bcc4d 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -24,6 +24,7 @@ import type { import { ClaudeAgentClient } from "./providers/claude/agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; +import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; import { OpenCodeServerManager } from "./providers/opencode/server-manager.js"; @@ -110,6 +111,12 @@ const PROVIDER_CLIENT_FACTORIES: Record = { logger, runtimeSettings, }), + cursor: (logger, runtimeSettings) => + new CursorACPAgentClient({ + logger, + command: getCursorACPCommand(runtimeSettings), + env: runtimeSettings?.env, + }), opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings), pi: (logger, runtimeSettings) => new PiDirectAgentClient({ @@ -119,6 +126,19 @@ const PROVIDER_CLIENT_FACTORIES: Record = { mock: (logger) => new MockLoadTestAgentClient(logger), }; +function getCursorACPCommand( + runtimeSettings: ProviderRuntimeSettings | undefined, +): [string, ...string[]] { + if ( + runtimeSettings?.command?.mode === "replace" && + isNonEmptyStringArray(runtimeSettings.command.argv) + ) { + return runtimeSettings.command.argv; + } + + return ["cursor-agent", "acp"]; +} + function getProviderClientFactory(provider: string): ProviderClientFactory { const factory = PROVIDER_CLIENT_FACTORIES[provider]; if (!factory) { @@ -513,13 +533,21 @@ function addDerivedProviders( enabled: override.enabled !== false, derivedFromProviderId: null, createBaseClient: (logger) => - new GenericACPAgentClient({ - logger, - command, - env: override.env, - providerId, - label: override.label ?? providerId, - }), + providerId === "cursor" + ? new CursorACPAgentClient({ + logger, + command, + env: override.env, + providerId, + label: override.label ?? providerId, + }) + : new GenericACPAgentClient({ + logger, + command, + env: override.env, + providerId, + label: override.label ?? providerId, + }), }); continue; } 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 new file mode 100644 index 000000000..438743e59 --- /dev/null +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js"; +import { CursorACPAgentClient, parseCursorAgentModelsOutput } from "./cursor-acp-agent.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import * as spawnUtils from "../../../utils/spawn.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("parseCursorAgentModelsOutput", () => { + test("parses Cursor model list output and marks default model", () => { + expect( + parseCursorAgentModelsOutput(` +Available models + +auto - Auto +composer-2-fast - Composer 2 Fast (default) +gpt-5.5-low - GPT-5.5 1M Low (current) + +Tip: use --model (or /model in interactive mode) to switch. +`), + ).toEqual([ + { provider: "acp", id: "auto", label: "Auto", isDefault: false }, + { + provider: "acp", + id: "composer-2-fast", + label: "Composer 2 Fast", + isDefault: true, + }, + { + provider: "acp", + id: "gpt-5.5-low", + label: "GPT-5.5 1M Low", + isDefault: false, + }, + ]); + }); + + test("falls back to first model as default when Cursor marks none", () => { + expect( + parseCursorAgentModelsOutput(` +Available models +composer-2 - Composer 2 +gpt-5.5-low - GPT-5.5 1M Low +`), + ).toEqual([ + { provider: "acp", id: "composer-2", label: "Composer 2", isDefault: true }, + { provider: "acp", id: "gpt-5.5-low", label: "GPT-5.5 1M Low", isDefault: false }, + ]); + }); +}); + +describe("CursorACPAgentClient model fallback", () => { + class TestCursorACPAgentClient extends CursorACPAgentClient { + constructor(options: { + command?: [string, ...string[]]; + env?: Record; + response: SessionStateResponse; + }) { + super({ + logger: createTestLogger(), + command: options.command ?? ["cursor-agent", "acp"], + env: options.env, + }); + this.response = options.response; + } + + private readonly response: SessionStateResponse; + + protected override async spawnProcess(): Promise { + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue(this.response), + }, + initialize: { agentCapabilities: {} }, + } as SpawnedACPProcess; + } + + protected override async closeProbe(): Promise {} + } + + test("uses cursor-agent models when Cursor ACP reports zero models", async () => { + const execCommand = vi.spyOn(spawnUtils, "execCommand").mockResolvedValue({ + stdout: "Available models\ncomposer-2-fast - Composer 2 Fast (default)\n", + stderr: "", + }); + const client = new TestCursorACPAgentClient({ + response: { sessionId: "session-1", models: null, configOptions: [] }, + }); + + const models = await client.listModels({ cwd: "/tmp/cursor", force: false }); + + expect(models).toEqual([ + { + provider: "acp", + id: "composer-2-fast", + label: "Composer 2 Fast", + isDefault: true, + }, + ]); + expect(execCommand).toHaveBeenCalledWith( + "cursor-agent", + ["models"], + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + test("uses cursor-agent models for absolute cursor-agent commands", async () => { + const execCommand = vi.spyOn(spawnUtils, "execCommand").mockResolvedValue({ + stdout: "Available models\ncomposer-2-fast - Composer 2 Fast (default)\n", + stderr: "", + }); + const client = new TestCursorACPAgentClient({ + command: ["/opt/cursor/bin/cursor-agent", "acp"], + response: { sessionId: "session-1", models: null, configOptions: [] }, + }); + + await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([ + { + provider: "acp", + id: "composer-2-fast", + label: "Composer 2 Fast", + isDefault: true, + }, + ]); + expect(execCommand).toHaveBeenCalledWith( + "/opt/cursor/bin/cursor-agent", + ["models"], + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + test("passes Cursor provider env to cursor-agent models fallback", async () => { + const execCommand = vi.spyOn(spawnUtils, "execCommand").mockResolvedValue({ + stdout: "Available models\ncomposer-2-fast - Composer 2 Fast (default)\n", + stderr: "", + }); + const env = { CURSOR_AGENT_LOG: "debug" }; + const client = new TestCursorACPAgentClient({ + env, + response: { sessionId: "session-1", models: null, configOptions: [] }, + }); + + await client.listModels({ cwd: "/tmp/cursor", force: false }); + + expect(execCommand).toHaveBeenCalledWith( + "cursor-agent", + ["models"], + expect.objectContaining({ envOverlay: env }), + ); + }); + + test("does not run fallback when ACP returns models", async () => { + const execCommand = vi.spyOn(spawnUtils, "execCommand"); + const client = new TestCursorACPAgentClient({ + response: { + sessionId: "session-1", + models: { + currentModelId: "acp-model", + availableModels: [{ modelId: "acp-model", name: "ACP Model", description: null }], + }, + configOptions: [], + }, + }); + + await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([ + { + provider: "acp", + id: "acp-model", + label: "ACP Model", + description: undefined, + isDefault: true, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }, + ]); + expect(execCommand).not.toHaveBeenCalled(); + }); + + test("does not run fallback when command is not cursor-agent", async () => { + const execCommand = vi.spyOn(spawnUtils, "execCommand"); + const client = new TestCursorACPAgentClient({ + command: ["other-agent", "acp"], + response: { sessionId: "session-1", models: null, configOptions: [] }, + }); + + await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([]); + expect(execCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts new file mode 100644 index 000000000..582d44c01 --- /dev/null +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -0,0 +1,125 @@ +import { basename } from "node:path"; +import type { Logger } from "pino"; + +import type { AgentModelDefinition, ListModelsOptions } from "../agent-sdk-types.js"; +import * as spawnUtils from "../../../utils/spawn.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +interface CursorACPAgentClientOptions { + logger: Logger; + command: [string, ...string[]]; + env?: Record; + providerId?: string; + label?: string; +} + +const CURSOR_MODELS_TIMEOUT_MS = 10_000; +const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/; + +export class CursorACPAgentClient extends GenericACPAgentClient { + private readonly cursorCommand: [string, ...string[]]; + private readonly env?: Record; + + constructor(options: CursorACPAgentClientOptions) { + super(options); + this.cursorCommand = options.command; + this.env = options.env; + } + + override async listModels(options: ListModelsOptions): Promise { + const acpModels = await super.listModels(options); + if (acpModels.length > 0) { + return acpModels; + } + + if (this.canUseCursorModelsFallback()) { + return this.listCursorModelsFallback(acpModels); + } + + return acpModels; + } + + private canUseCursorModelsFallback(): boolean { + return basename(this.cursorCommand[0]) === "cursor-agent"; + } + + private async listCursorModelsFallback( + acpModels: AgentModelDefinition[], + ): Promise { + try { + const { stdout } = await spawnUtils.execCommand(this.cursorCommand[0], ["models"], { + envOverlay: this.env, + timeout: CURSOR_MODELS_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + }); + const fallbackModels = parseCursorAgentModelsOutput(stdout); + if (fallbackModels.length === 0) { + this.logger.warn( + { provider: "cursor" }, + "Cursor ACP model fallback returned no parseable models", + ); + return acpModels; + } + return fallbackModels; + } catch (error) { + this.logger.warn( + { err: error, provider: "cursor" }, + "Failed to list Cursor models via cursor-agent models fallback", + ); + return acpModels; + } + } +} + +interface ParsedCursorModel { + provider: "acp"; + id: string; + label: string; + marker: "default" | "current" | null; +} + +export function parseCursorAgentModelsOutput(output: string): AgentModelDefinition[] { + const parsed = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && line !== "Available models" && !line.startsWith("Tip:")) + .map((line) => { + const separatorIndex = line.indexOf(" - "); + if (separatorIndex <= 0) { + return null; + } + + const id = line.slice(0, separatorIndex).trim(); + const rawLabel = line.slice(separatorIndex + 3).trim(); + if (!id || !rawLabel) { + return null; + } + + let marker: "default" | "current" | null = null; + if (rawLabel.endsWith(" (default)")) { + marker = "default"; + } else if (rawLabel.endsWith(" (current)")) { + marker = "current"; + } + + return { + provider: "acp", + id, + label: rawLabel.replace(CURSOR_MODEL_MARKER_PATTERN, ""), + marker, + }; + }) + .filter((model): model is ParsedCursorModel => model !== null); + + const defaultModelId = + parsed.find((model) => model.marker === "default")?.id ?? + parsed.find((model) => model.marker === "current")?.id ?? + parsed[0]?.id; + + return parsed.map((model) => ({ + provider: model.provider, + id: model.id, + label: model.label, + isDefault: model.id === defaultModelId, + })); +}