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
15 changes: 15 additions & 0 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Auth } from "@opencode-ai/sdk/v2";
import type { McpServerConfig } from "@cursor/sdk";
import { resolveCursorApiKey } from "../api-key.js";
import { discoverModels, toOpencodeModels } from "../model-discovery.js";
import { defaultModelParams } from "../model-variants.js";
import { buildModelV2Map, PROVIDER_ID, providerNpm } from "./model-v2.js";
import {
findUnshareableOAuthServers,
Expand Down Expand Up @@ -117,6 +118,17 @@ export const CursorPlugin: Plugin = async (input) => {
? { ...userMcp, ...translateMcpServers(config.mcp) }
: userMcp;

// opencode forwards a model's own options.params on the normal chat
// path, but a subagent inheriting its parent's model reaches the provider
// with them dropped — letting Cursor's server-side `fast: true` apply.
// Thread the defaults through provider options (per-provider, survives
// the drop) so the provider can re-apply them as a floor.
const modelParamDefaults: Record<string, Record<string, string>> = {};
for (const item of models) {
const params = defaultModelParams(item);
if (Object.keys(params).length > 0) modelParamDefaults[item.id] = params;
}

// One canonical cwd for the provider's rule write and our dispose
// cleanup: an explicit user option wins, else the plugin directory.
const optionCwd = existingOptions["cwd"];
Expand All @@ -133,6 +145,9 @@ export const CursorPlugin: Plugin = async (input) => {
...existingOptions,
cwd: resolvedCwd,
...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}),
...(Object.keys(modelParamDefaults).length > 0
? { modelParamDefaults }
: {}),
},
models: { ...toOpencodeModels(models), ...(existing.models ?? {}) },
};
Expand Down
11 changes: 10 additions & 1 deletion src/provider/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ export interface StaticControls {
mode: AgentModeOption;
/** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */
params?: Record<string, string>;
/**
* Per-model floor params, applied UNDER {@link params} and per-request options
* (an explicit param always wins). Pins Cursor's boolean toggles, e.g.
* `{ fast: "false" }`, when a turn arrives with no params of its own.
*/
defaults?: Record<string, string>;
}

export interface ResolvedControls {
Expand Down Expand Up @@ -52,7 +58,10 @@ export function resolveControls(

const mode: AgentModeOption = isMode(po["mode"]) ? po["mode"] : staticControls.mode;

const params: Record<string, string> = { ...(staticControls.params ?? {}) };
const params: Record<string, string> = {
...(staticControls.defaults ?? {}),
...(staticControls.params ?? {}),
};
if (isRecord(po["params"])) {
for (const [key, value] of Object.entries(po["params"])) {
if (value != null) params[key] = String(value);
Expand Down
9 changes: 9 additions & 0 deletions src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export interface CursorProviderOptions {
mode?: AgentModeOption;
/** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */
params?: Record<string, string>;
/**
* Per-model floor params keyed by model id, e.g. `{ "composer-2.5": { fast:
* "false" } }`. Seeded by the plugin's `config` hook; applied under `params`
* and per-request options.
*/
modelParamDefaults?: Record<string, Record<string, string>>;
/**
* MCP servers to make available to the Cursor agent, keyed by name. The
* plugin's `config` hook populates this by translating opencode's configured
Expand Down Expand Up @@ -98,6 +104,9 @@ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 {
cwd: options.cwd ?? process.cwd(),
mode: options.mode ?? "agent",
...(options.params ? { params: options.params } : {}),
...(options.modelParamDefaults
? { modelParamDefaults: options.modelParamDefaults }
: {}),
...(mcpServers ? { mcpServers } : {}),
...(options.settingSources
? { settingSources: options.settingSources }
Expand Down
16 changes: 15 additions & 1 deletion src/provider/language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export interface CursorModelConfig {
mode: AgentModeOption;
/** Default Cursor model params (id -> value); overridable per-request. */
params?: Record<string, string>;
/**
* Per-model floor params keyed by model id, seeded by the plugin's `config`
* hook. Passed as {@link resolveControls}'s `defaults` for the active model.
*/
modelParamDefaults?: Record<string, Record<string, string>>;
/** MCP servers forwarded to the Cursor agent from opencode's config. */
mcpServers?: Record<string, McpServerConfig>;
/** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */
Expand Down Expand Up @@ -140,9 +145,18 @@ export class CursorLanguageModel implements LanguageModelV3 {
| undefined;
const { mode, modelSelection } = resolveControls(
this.modelId,
{ mode: this.config.mode, params: this.config.params },
{
mode: this.config.mode,
params: this.config.params,
defaults: this.config.modelParamDefaults?.[this.modelId],
},
providerOptions,
);
if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
console.error(
`[cursor:debug] model=${this.modelId} selection=${JSON.stringify(modelSelection)}`,
);
}
const sessionID =
typeof providerOptions?.["sessionID"] === "string"
? (providerOptions["sessionID"] as string)
Expand Down
30 changes: 30 additions & 0 deletions test/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ describe("resolveControls", () => {
const r = resolveControls("m", { mode: "agent" }, { params: { budget: 1024 } });
expect(r.modelSelection.params).toEqual([{ id: "budget", value: "1024" }]);
});

it("applies per-model defaults as a floor when no params are supplied", () => {
// The subagent path: opencode hands the bare model id with no params, so the
// model's default `fast: "false"` must still be sent (otherwise Cursor's
// server-side `fast: true` default silently applies).
const r = resolveControls(
"composer-2.5",
{ mode: "agent", defaults: { fast: "false" } },
undefined,
);
expect(r.modelSelection.params).toEqual([{ id: "fast", value: "false" }]);
});

it("lets a per-request param override the default floor (fast opt-in)", () => {
const r = resolveControls(
"composer-2.5",
{ mode: "agent", defaults: { fast: "false" } },
{ params: { fast: "true" } },
);
expect(r.modelSelection.params).toEqual([{ id: "fast", value: "true" }]);
});

it("lets static params override the default floor", () => {
const r = resolveControls(
"m",
{ mode: "agent", defaults: { fast: "false" }, params: { fast: "true" } },
undefined,
);
expect(r.modelSelection.params).toEqual([{ id: "fast", value: "true" }]);
});
});

// buildModelVariants behavior is covered in test/model-variants.test.ts.
30 changes: 30 additions & 0 deletions test/language-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,36 @@ describe("CursorLanguageModel doStream — resume-aware retry", () => {
expect(getPooledAgentId("s1")).toBeUndefined();
});

it("applies per-model default params (fast:false) when a turn arrives with no params", async () => {
// Simulates an opencode subagent that inherited its parent's model: the
// provider gets the bare model id with no per-request params. The
// modelParamDefaults floor must still pin fast:false so Cursor's
// server-side fast:true default never applies.
const model = new CursorLanguageModel("composer-2.5", {
providerName: "cursor",
cwd: "/tmp",
mode: "agent",
session: "auto",
modelParamDefaults: { "composer-2.5": { fast: "false" } },
});
create.mockResolvedValueOnce(fakeAgent({ agentId: "a1" }));

await collectStream(
streamCall(model, {
prompt: [sys("S"), user("hi")],
providerOptions: { cursor: { sessionID: "s1" } },
} as never),
);

const acquireArgs = create.mock.calls[0]?.[0] as {
model: { id: string; params?: Array<{ id: string; value: string }> };
};
expect(acquireArgs.model).toEqual({
id: "composer-2.5",
params: [{ id: "fast", value: "false" }],
});
});

it("chains the original resume failure as cause when re-acquire throws", async () => {
const model = makeModel();

Expand Down