Use only selectable Cursor ACP models (#1556)

This commit is contained in:
Mohamed Boudra
2026-06-16 05:07:24 +00:00
committed by GitHub
parent af0437e6df
commit e90f398ac0
2 changed files with 24 additions and 254 deletions
@@ -1,70 +1,17 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js";
import { CursorACPAgentClient, parseCursorAgentModelsOutput } from "./cursor-acp-agent.js";
import { CursorACPAgentClient } 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 <id> (or /model <id> 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", () => {
describe("CursorACPAgentClient model discovery", () => {
class TestCursorACPAgentClient extends CursorACPAgentClient {
constructor(options: {
command?: [string, ...string[]];
env?: Record<string, string>;
response: SessionStateResponse;
}) {
constructor(response: SessionStateResponse) {
super({
logger: createTestLogger(),
command: options.command ?? ["cursor-agent", "acp"],
env: options.env,
command: ["cursor-agent", "acp"],
});
this.response = options.response;
this.response = response;
}
private readonly response: SessionStateResponse;
@@ -82,112 +29,42 @@ describe("CursorACPAgentClient model fallback", () => {
protected override async closeProbe(): Promise<void> {}
}
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: "",
});
test("returns only ACP model ids because Cursor CLI ids cannot select ACP models", async () => {
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,
sessionId: "session-1",
models: {
currentModelId: "gpt-5.4[context=272k,reasoning=medium,fast=false]",
availableModels: [
{
modelId: "gpt-5.4[context=272k,reasoning=medium,fast=false]",
name: "gpt-5.4",
description: null,
},
],
},
]);
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: [] },
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",
id: "gpt-5.4[context=272k,reasoning=medium,fast=false]",
label: "gpt-5.4",
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");
test("does not fall back to cursor-agent models when ACP reports zero models", async () => {
const client = new TestCursorACPAgentClient({
command: ["other-agent", "acp"],
response: { sessionId: "session-1", models: null, configOptions: [] },
sessionId: "session-1",
models: null,
configOptions: [],
});
await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([]);
expect(execCommand).not.toHaveBeenCalled();
});
});
@@ -1,8 +1,5 @@
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 {
@@ -14,14 +11,9 @@ interface CursorACPAgentClientOptions {
providerParams?: unknown;
}
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
const CURSOR_INITIAL_COMMANDS_WAIT_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<string, string>;
constructor(options: CursorACPAgentClientOptions) {
super({
logger: options.logger,
@@ -34,104 +26,5 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
});
this.cursorCommand = options.command;
this.env = options.env;
}
override async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
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<AgentModelDefinition[]> {
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,
}));
}