From 681b83f8ecec2dc6a4ee58d1adc0aefca20c7398 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 16 Jul 2026 14:33:30 -0500 Subject: [PATCH 1/2] fix(provider): pin per-model default params on subagent turns Subagents inherit the primary agent's model but reach the provider with the model's opencode options.params dropped, so Cursor's server-side `fast: true` default silently applied (e.g. composer-2.5 ran "fast" in subagent calls). Thread each discovered model's defaultModelParams through provider options (a per-provider channel that survives opencode's subagent param-drop) and re-apply them as a lowest-precedence floor in resolveControls, so `fast: "false"` holds even when per-request params are absent. Explicit variant/static params still win. Add an OPENCODE_CURSOR_DEBUG log of the resolved modelSelection to confirm per-turn behavior. --- src/plugin/index.ts | 17 +++++++++++++++++ src/provider/controls.ts | 15 ++++++++++++++- src/provider/index.ts | 11 +++++++++++ src/provider/language-model.ts | 25 ++++++++++++++++++++++++- test/controls.test.ts | 30 ++++++++++++++++++++++++++++++ test/language-model.test.ts | 30 ++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 8536d6a..6ae6444 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -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, @@ -117,6 +118,19 @@ export const CursorPlugin: Plugin = async (input) => { ? { ...userMcp, ...translateMcpServers(config.mcp) } : userMcp; + // Per-model floor params (e.g. { "composer-2.5": { fast: "false" } }). + // opencode merges each model's own `options.params` on the normal chat + // path, but a subagent that inherits its parent agent's model can reach + // the provider with the bare model id and no params. Threading these + // defaults through the (per-provider, not per-request) provider options + // lets the provider re-apply them as a floor so `fast` never silently + // falls back to Cursor's server-side `true`. + const modelParamDefaults: Record> = {}; + 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"]; @@ -133,6 +147,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 ?? {}) }, }; diff --git a/src/provider/controls.ts b/src/provider/controls.ts index 24a4b31..64e3176 100644 --- a/src/provider/controls.ts +++ b/src/provider/controls.ts @@ -5,6 +5,16 @@ export interface StaticControls { mode: AgentModeOption; /** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */ params?: Record; + /** + * Per-model floor params applied UNDER {@link params} and per-request options. + * Carries this model's non-reasoning boolean defaults (e.g. `{ fast: "false" }`) + * so a call that reaches the provider with the bare model id and no params — + * notably an opencode subagent inheriting its parent's model — still pins + * `fast` off instead of inheriting Cursor's server-side `fast: true` default. + * The normal chat path already carries these via the model's opencode + * `options.params`, so re-applying them here is a no-op there. + */ + defaults?: Record; } export interface ResolvedControls { @@ -52,7 +62,10 @@ export function resolveControls( const mode: AgentModeOption = isMode(po["mode"]) ? po["mode"] : staticControls.mode; - const params: Record = { ...(staticControls.params ?? {}) }; + const params: Record = { + ...(staticControls.defaults ?? {}), + ...(staticControls.params ?? {}), + }; if (isRecord(po["params"])) { for (const [key, value] of Object.entries(po["params"])) { if (value != null) params[key] = String(value); diff --git a/src/provider/index.ts b/src/provider/index.ts index de224ae..fedd726 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -33,6 +33,14 @@ export interface CursorProviderOptions { mode?: AgentModeOption; /** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */ params?: Record; + /** + * Per-model floor params keyed by model id (e.g. `{ "composer-2.5": { fast: + * "false" } }`). Seeded by the plugin's `config` hook from the discovered + * catalog so subagents that inherit a model without its options.params still + * pin Cursor's boolean toggles to their opencode defaults. Applied under + * `params` and per-request options. + */ + modelParamDefaults?: Record>; /** * MCP servers to make available to the Cursor agent, keyed by name. The * plugin's `config` hook populates this by translating opencode's configured @@ -98,6 +106,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 } diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index 60261fb..ed643f1 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -55,6 +55,16 @@ export interface CursorModelConfig { mode: AgentModeOption; /** Default Cursor model params (id -> value); overridable per-request. */ params?: Record; + /** + * Per-model floor params keyed by model id (e.g. `{ "composer-2.5": { fast: + * "false" } }`). Applied under {@link params} and per-request options so a + * call arriving with the bare model id and no params — notably an opencode + * subagent that inherited its parent agent's model — still pins Cursor's + * boolean toggles (like `fast`) to their opencode defaults instead of + * silently inheriting Cursor's server-side `fast: true`. Seeded by the + * plugin's `config` hook from the discovered catalog. + */ + modelParamDefaults?: Record>; /** MCP servers forwarded to the Cursor agent from opencode's config. */ mcpServers?: Record; /** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */ @@ -140,9 +150,22 @@ 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") { + // Root-cause instrument: shows the exact ModelSelection sent to Cursor. + // A subagent that inherited its parent's model with dropped params shows + // up here as a selection missing the `fast: "false"` floor before the + // modelParamDefaults guard re-applies it. + console.error( + `[cursor:debug] model=${this.modelId} selection=${JSON.stringify(modelSelection)}`, + ); + } const sessionID = typeof providerOptions?.["sessionID"] === "string" ? (providerOptions["sessionID"] as string) diff --git a/test/controls.test.ts b/test/controls.test.ts index 5aa2521..2774401 100644 --- a/test/controls.test.ts +++ b/test/controls.test.ts @@ -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. diff --git a/test/language-model.test.ts b/test/language-model.test.ts index de56788..2d587e9 100644 --- a/test/language-model.test.ts +++ b/test/language-model.test.ts @@ -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(); From 7753329d11bcbdf355e841255284e5ed6506a66a Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 16 Jul 2026 14:37:50 -0500 Subject: [PATCH 2/2] style: trim redundant comments on subagent param floor Collapse the repeated subagent/fast rationale to a single home (the plugin config hook where the map originates); reduce the type-field docstrings to one-liners and drop the self-evident debug-log comment. --- src/plugin/index.ts | 12 +++++------- src/provider/controls.ts | 10 +++------- src/provider/index.ts | 8 +++----- src/provider/language-model.ts | 13 ++----------- 4 files changed, 13 insertions(+), 30 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 6ae6444..a7d0352 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -118,13 +118,11 @@ export const CursorPlugin: Plugin = async (input) => { ? { ...userMcp, ...translateMcpServers(config.mcp) } : userMcp; - // Per-model floor params (e.g. { "composer-2.5": { fast: "false" } }). - // opencode merges each model's own `options.params` on the normal chat - // path, but a subagent that inherits its parent agent's model can reach - // the provider with the bare model id and no params. Threading these - // defaults through the (per-provider, not per-request) provider options - // lets the provider re-apply them as a floor so `fast` never silently - // falls back to Cursor's server-side `true`. + // 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> = {}; for (const item of models) { const params = defaultModelParams(item); diff --git a/src/provider/controls.ts b/src/provider/controls.ts index 64e3176..8292b83 100644 --- a/src/provider/controls.ts +++ b/src/provider/controls.ts @@ -6,13 +6,9 @@ export interface StaticControls { /** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */ params?: Record; /** - * Per-model floor params applied UNDER {@link params} and per-request options. - * Carries this model's non-reasoning boolean defaults (e.g. `{ fast: "false" }`) - * so a call that reaches the provider with the bare model id and no params — - * notably an opencode subagent inheriting its parent's model — still pins - * `fast` off instead of inheriting Cursor's server-side `fast: true` default. - * The normal chat path already carries these via the model's opencode - * `options.params`, so re-applying them here is a no-op there. + * 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; } diff --git a/src/provider/index.ts b/src/provider/index.ts index fedd726..af3cdab 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -34,11 +34,9 @@ export interface CursorProviderOptions { /** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */ params?: Record; /** - * Per-model floor params keyed by model id (e.g. `{ "composer-2.5": { fast: - * "false" } }`). Seeded by the plugin's `config` hook from the discovered - * catalog so subagents that inherit a model without its options.params still - * pin Cursor's boolean toggles to their opencode defaults. Applied under - * `params` and per-request options. + * 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>; /** diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index ed643f1..2b47981 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -56,13 +56,8 @@ export interface CursorModelConfig { /** Default Cursor model params (id -> value); overridable per-request. */ params?: Record; /** - * Per-model floor params keyed by model id (e.g. `{ "composer-2.5": { fast: - * "false" } }`). Applied under {@link params} and per-request options so a - * call arriving with the bare model id and no params — notably an opencode - * subagent that inherited its parent agent's model — still pins Cursor's - * boolean toggles (like `fast`) to their opencode defaults instead of - * silently inheriting Cursor's server-side `fast: true`. Seeded by the - * plugin's `config` hook from the discovered catalog. + * 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>; /** MCP servers forwarded to the Cursor agent from opencode's config. */ @@ -158,10 +153,6 @@ export class CursorLanguageModel implements LanguageModelV3 { providerOptions, ); if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") { - // Root-cause instrument: shows the exact ModelSelection sent to Cursor. - // A subagent that inherited its parent's model with dropped params shows - // up here as a selection missing the `fast: "false"` floor before the - // modelParamDefaults guard re-applies it. console.error( `[cursor:debug] model=${this.modelId} selection=${JSON.stringify(modelSelection)}`, );