diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 1778df3cc..c44682a0b 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -136,6 +136,12 @@ export interface AcpSupport { sessionId: string; config: AcpConfig; turn: SendTurnInput; + /** `session/new` (or `session/load`) advertised model list, verbatim. Some + * CLIs namespace their ACP model ids differently from their argv `--model` + * slugs (Cursor answers `default[]` where the CLI calls it `auto`), so a + * driver that only knows the argv slug cannot form a valid set_model + * without this. Empty when the agent advertised none. */ + sessionModels: Array<{ modelId?: string; name?: string }>; }): Promise; } @@ -620,6 +626,9 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver sessionId, config, turn: cliTurn, + sessionModels: Array.isArray(sessionResult?.models?.availableModels) + ? sessionResult.models.availableModels + : [], }); // initialize's currentModelId is the CLI default (grok-4.6), // not the model this turn asked for. After a successful pin, diff --git a/server/drivers/acp/cursor.test.ts b/server/drivers/acp/cursor.test.ts index 904584462..905162abf 100644 --- a/server/drivers/acp/cursor.test.ts +++ b/server/drivers/acp/cursor.test.ts @@ -16,6 +16,7 @@ import { decodeCursorModelCatalog, decodeCursorModelText, STATIC_CURSOR_MODELS, + resolveCursorAcpModelId, } from "./cursor.ts"; const FAKE_CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "testing", "fake-acp-cli.ts"); @@ -287,3 +288,111 @@ describe("CursorAgentDriver", () => { } }); }); + +describe("resolveCursorAcpModelId", () => { + // Real payload shape from `session/new` against cursor-agent 2026.08.11. + const ADVERTISED = [ + { modelId: "default[]", name: "Auto" }, + { modelId: "grok-4.6[effort=high,fast=true]", name: "grok-4.6" }, + { modelId: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "gpt-5.3-codex" }, + ]; + + it("maps the argv slug `auto` onto Cursor's `default[]` entry", () => { + // The bug: `auto` is what --model and `cursor-agent models` call it, and + // it earns -32602 over ACP because the session only knows `default[]`. + expect(resolveCursorAcpModelId(ADVERTISED, "auto")).toBe("default[]"); + }); + + it("maps a bare slug onto its parameterised id", () => { + expect(resolveCursorAcpModelId(ADVERTISED, "gpt-5.3-codex")).toBe( + "gpt-5.3-codex[reasoning=medium,fast=false]", + ); + }); + + it("maps a display name onto its parameterised id", () => { + expect( + resolveCursorAcpModelId( + [{ modelId: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }], + "Codex 5.3", + ), + ).toBe("gpt-5.3-codex[reasoning=medium,fast=false]"); + }); + + it("passes an already-parameterised id straight through", () => { + expect(resolveCursorAcpModelId(ADVERTISED, "grok-4.6[effort=high,fast=true]")).toBe( + "grok-4.6[effort=high,fast=true]", + ); + }); + + it("returns null when the agent advertised no models, so the caller keeps the argv slug", () => { + expect(resolveCursorAcpModelId([], "auto")).toBeNull(); + }); + + it("returns null for a model this session does not offer", () => { + expect(resolveCursorAcpModelId(ADVERTISED, "no-such-model")).toBeNull(); + }); +}); + +describe("cursor ACP model namespace (NS: set_model wiring)", () => { + it("sends the session's parameterised id, not the argv slug", async () => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + const scratch = mkdtempSync(join(tmpdir(), "omb-cursor-acpid-")); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + // What cursor-agent 2026.08.11 really advertises: `auto` is `default[]`. + process.env.FAKE_ACP_SESSION_MODELS = "default[]|Auto,gpt-5.3-codex[reasoning=medium,fast=false]|gpt-5.3-codex"; + + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-acpid", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ threadId: "t-cursor-acpid", text: "hi", model: "auto" }); + await recorder.until((e) => e.type === "turn.completed"); + const applied = JSON.parse(readFileSync(`${dump}.config.json`, "utf8")); + // Before the fix this sent modelId "auto" and Cursor answered -32602. + expect(applied).toEqual([ + { method: "session/set_model", params: { sessionId: "fake-acp-session", modelId: "default[]" } }, + ]); + // argv keeps the CLI slug — the two namespaces stay separate. + expect(JSON.parse(readFileSync(dump, "utf8")).argv).toEqual(["--model", "auto", "acp"]); + } finally { + recorder.stop(); + await instance.dispose(); + delete process.env.FAKE_ACP_SESSION_MODELS; + // the dir goes with it: a stale FAKE_ACP_DUMP makes the *next* test's + // fake CLI die on ENOENT, which reads as an unrelated driver failure. + delete process.env.FAKE_ACP_DUMP; + await removeTempDir(scratch); + } + }); + + it("completes the turn when set_model answers -32602, because argv already pinned the model", async () => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + process.env.FAKE_ACP_MODE = "set-model-invalid-params"; + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-invalid", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ threadId: "t-cursor-invalid", text: "hi", model: "gpt-5.3-codex" }); + const done = await recorder.until((e) => e.type === "turn.completed"); + // Previously this threw and failed a turn that would have run correctly. + expect(done).toMatchObject({ type: "turn.completed", ok: true }); + } finally { + recorder.stop(); + await instance.dispose(); + delete process.env.FAKE_ACP_MODE; + } + }); +}); diff --git a/server/drivers/acp/cursor.ts b/server/drivers/acp/cursor.ts index 599407c75..ca4a4336c 100644 --- a/server/drivers/acp/cursor.ts +++ b/server/drivers/acp/cursor.ts @@ -12,6 +12,50 @@ import type { ModelCatalog, ProviderErrorCode } from "../../contracts.ts"; import { execCli } from "../../procs.ts"; import { createAcpDriver, type AcpSupport } from "./core.ts"; +/** Translate an argv `--model` slug into the id this ACP session will accept. + * + * Cursor keeps two model namespaces and they do not match. `cursor-agent + * models` and the `--model` flag speak flat slugs (`auto`, `gpt-5.3-codex`). + * The ACP session advertises parameterised ids instead + * (`default[]`, `gpt-5.3-codex[reasoning=medium,fast=false]`), and + * `session/set_model` accepts *only* those. Sending the argv slug earns + * `-32602 Invalid params` for every model, not merely unknown ones — which + * read as "this account cannot use that model" and sent people to check their + * subscription over a pure id-format mismatch. + * + * Matching walks from most to least specific, and `auto` is special-cased + * because Cursor calls that entry `default[]` while naming it "Auto". + * + * Returns null when nothing matches, including when the agent advertised no + * models at all. The caller then falls back to sending the slug unchanged, + * which is what older CLIs that ignore the model list still expect. + */ +export function resolveCursorAcpModelId( + available: Array<{ modelId?: string; name?: string }>, + wanted: string, +): string | null { + const want = wanted.trim().toLowerCase(); + if (!want) return null; + const ids = available.filter((m) => typeof m?.modelId === "string" && m.modelId); + if (!ids.length) return null; + const base = (id: string) => id.split("[")[0].trim().toLowerCase(); + + const exact = ids.find((m) => m.modelId!.toLowerCase() === want); + if (exact) return exact.modelId!; + + const byBase = ids.find((m) => base(m.modelId!) === want); + if (byBase) return byBase.modelId!; + + const byName = ids.find((m) => (m.name ?? "").trim().toLowerCase() === want); + if (byName) return byName.modelId!; + + if (want === "auto" || want === "default") { + const dflt = ids.find((m) => base(m.modelId!) === "default"); + if (dflt) return dflt.modelId!; + } + return null; +} + export const STATIC_CURSOR_MODELS: ModelCatalog = { default: "auto", options: [ @@ -325,15 +369,21 @@ const support = (run: typeof execCli): AcpSupport => ({ isAuthenticated: (env, config) => probeCursorAuth(config.cli || "cursor-agent", env, run), classifyError: classifyCursorError, - async configureSession({ request, sessionId, turn }) { + async configureSession({ request, sessionId, turn, sessionModels }) { if (!turn.model) return; + // Prefer the id this session actually advertised; fall back to the argv + // slug so a CLI that advertises nothing behaves exactly as before. + const modelId = resolveCursorAcpModelId(sessionModels ?? [], turn.model) ?? turn.model; try { - await request("session/set_model", { sessionId, modelId: turn.model }); + await request("session/set_model", { sessionId, modelId }); } catch (e) { const err = e as Error & { code?: unknown }; - if (err.code === -32601) return; + // -32601 method missing, -32602 id not in this session's namespace. In + // both cases spawnArgs already pinned `--model`, so the turn runs the + // right model anyway; failing it here would refuse a working request. + if (err.code === -32601 || err.code === -32602) return; throw new Error( - `Cursor rejected model "${turn.model}" via session/set_model: ${err.message}. ` + + `Cursor rejected model "${turn.model}" (sent as "${modelId}") via session/set_model: ${err.message}. ` + `Check that \`cursor-agent\` is current and that this account can use that model.`, ); } diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index ddf42d92b..88dad9c68 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -54,6 +54,20 @@ const configOptions = () => }, ] : null; +// cursor-shaped surface: the session advertises `models.availableModels` with +// parameterised ids (`default[]`) that differ from the argv `--model` slugs +// (`auto`). Off unless FAKE_ACP_SESSION_MODELS is set, so every existing mode +// stays byte-identical. Format: "id|Name,id|Name" — the name is optional. +const acpModels = (process.env.FAKE_ACP_SESSION_MODELS ?? "") + .split(",") + .filter(Boolean) + .map((entry) => { + const [modelId, name] = entry.split("|"); + return name ? { modelId, name } : { modelId }; + }); +const sessionModels = () => + acpModels.length ? { currentModelId: acpModels[0].modelId, availableModels: acpModels } : null; + const argv = process.argv.slice(2); const dumpEnv = Object.fromEntries( [ @@ -248,12 +262,18 @@ function handle(msg: any) { writeFileSync(`${process.env.FAKE_ACP_DUMP}.mcp.json`, JSON.stringify(servers, null, 2)); } const opts = configOptions(); - result(msg.id, opts ? { sessionId: "fake-acp-session", configOptions: opts } : { sessionId: "fake-acp-session" }); + const mdls = sessionModels(); + result(msg.id, { + sessionId: "fake-acp-session", + ...(opts ? { configOptions: opts } : {}), + ...(mdls ? { models: mdls } : {}), + }); break; } case "session/load": { const opts = configOptions(); - result(msg.id, opts ? { configOptions: opts } : {}); + const mdls = sessionModels(); + result(msg.id, { ...(opts ? { configOptions: opts } : {}), ...(mdls ? { models: mdls } : {}) }); break; } // per-session settings (droid sets model/autonomy here, not via argv). @@ -266,6 +286,11 @@ function handle(msg: any) { // an older agent that predates these methods return out({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } }); } + if (mode === "set-model-invalid-params" && msg.method === "session/set_model") { + // an agent whose ACP model namespace does not contain the id it was + // sent — Cursor's answer when handed an argv slug like `auto`. + return out({ jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: "Invalid params" } }); + } const settingId = msg.method === "session/set_mode" ? "modeId" : "modelId"; if (typeof msg.params?.sessionId !== "string" || typeof msg.params?.[settingId] !== "string") { out({