Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions server/drivers/acp/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

Expand Down Expand Up @@ -620,6 +626,9 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
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,
Expand Down
109 changes: 109 additions & 0 deletions server/drivers/acp/cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}
});
});
58 changes: 54 additions & 4 deletions server/drivers/acp/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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.`,
);
}
Expand Down
29 changes: 27 additions & 2 deletions server/testing/fake-acp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -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).
Expand All @@ -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({
Expand Down
Loading