From 4b87733beb55df44e98b320a6a9b5719c8f912b0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 17:20:22 -0500 Subject: [PATCH 1/5] fix(acp-registry): Cursor offers a reasoning effort picker per model Cursor over ACP lists one fixed preset per model unless the client sends clientCapabilities._meta.parameterizedModelPicker. Send it from Cursor sessions and from the discovery probe, so the model list and the live session agree on plain model ids, and open a saved preset id on its plain model. Fixes #24371 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 65 +++++++++++++++++++ .../src/provider/AcpRegistryProbe.test.ts | 39 +++++++++++ .../src/server/adapter.ts | 7 ++ .../src/server/cursorAcp.ts | 20 ++++++ .../provider-acp-registry/src/server/probe.ts | 3 + packages/provider-acp-registry/src/testing.ts | 1 + 6 files changed, 135 insertions(+) create mode 100644 packages/provider-acp-registry/src/server/cursorAcp.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index fab0020b7e03e..d95f2490451fc 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1007,6 +1007,71 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // regression: #24371 Cursor lists only fixed effort presets unless asked for its picker + it.effect("opens Cursor on a plain model with the reasoning effort the thread picked", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("acp-registry-cursor"); + const openCursor = Effect.fn("openCursor")(function* (stored: ModelSelection) { + let published: AcpRegistryLiveConfiguration | undefined; + const adapter = yield* makeAcpRegistryAdapterV2({ + selfInvocation: yield* resolveSelfInvocation(), + instanceId, + settings: yield* decodeAcpRegistryAdapterSettings({ agentId: "cursor" }), + environment: {}, + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({ + resolve: (_settings, cwd) => + Effect.succeed({ + distribution: "local" as const, + spawn: { command: process.execPath, args: [mockAgentPath], cwd }, + }), + }), + Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({ + withForegroundStartup: (_startupKey, effect) => effect, + publishLiveConfiguration: (_instanceId, configuration) => + Effect.sync(() => { + published = configuration; + }), + }), + ), + ), + ); + yield* adapter + .openSession({ + threadId: ThreadId.make("thread-acp-registry-cursor"), + providerSessionId: ProviderSessionId.make("provider-session-acp-registry-cursor"), + modelSelection: stored, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }), + }) + .pipe(Effect.scoped); + return published; + }); + + const picked = yield* openCursor({ + instanceId, + model: "gpt-5.4", + options: [{ id: "reasoning", value: "extra-high" }], + }); + assert.equal(picked?.currentModelId, "gpt-5.4"); + assert.deepInclude( + picked?.configOptions.find((option) => option.id === "reasoning"), + { currentValue: "extra-high" }, + ); + const saved = yield* openCursor({ instanceId, model: "composer-2[fast=true]" }); + assert.equal(saved?.currentModelId, "composer-2"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("settles Claude's accepted foreground reply while retaining child output", () => Effect.gen(function* () { const gate = makeProviderReplayGate(["accepted", "child-output", "wake"]); diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index bc7c14d3b3c8a..35343fc29fc48 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -18,6 +18,7 @@ import { logoutAcpRegistry, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, + probeAcpRegistryConfiguration, } from "@t3tools/provider-acp-registry/testing"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; @@ -374,6 +375,44 @@ describe("ACP Registry probe", () => { ); }); + // regression: #24371 Cursor lists only fixed effort presets unless asked for its picker + // Live clock: the probe waits a short real grace for command advertisements. + it.live("lists Cursor's plain model ids so its effort stays a separate option", () => { + const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ + search: () => Effect.die("unused search"), + prepare: () => Effect.die("unused prepare"), + inspect: () => Effect.die("unused inspect"), + uninstallManagedBinary: () => Effect.die("unused uninstall"), + resolve: () => + Effect.succeed({ + distribution: "local" as const, + spawn: { command: "node", args: [mockAgentPath] }, + }), + }); + const probedModels = (agentId: string) => + probeAcpRegistryConfiguration({ + instanceId, + settings: decodeSettings({ agentId }), + cwd: process.cwd(), + environment: process.env, + }).pipe(Effect.map((result) => result.probe.models.map((model) => model.id))); + + return Effect.gen(function* () { + expect(yield* probedModels("cursor")).toEqual([ + "default", + "composer-2", + "gpt-5.4", + "claude-opus-4-6", + ]); + expect(yield* probedModels("mock-agent")).toContain( + "gpt-5.3-codex[reasoning=medium,fast=false]", + ); + }).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), + Effect.provide(NodeServices.layer), + ); + }); + it("distinguishes authentication failures from generic probe failures", () => { const advertised = normalizeAcpRegistryAuthMethods([ { id: "grok-login", name: "Log in with Grok" }, diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index d8eeca222bdee..5f610818ea85d 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -14,6 +14,7 @@ import { extractCopilotSubagentUpdate, makeCopilotSubagentRouting, } from "./copilotAcp.ts"; +import { cursorBaseModelId, cursorClientCapabilitiesMeta, isCursorAcpAgent } from "./cursorAcp.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; import { readCustomModelEntries } from "@t3tools/shared/model"; import { AcpRegistrySettings } from "../settings.ts"; @@ -238,6 +239,12 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu extractSubagentUpdate: extractDevinSubagentUpdate, } : {}), + ...(isCursorAcpAgent(options.settings) + ? { + clientCapabilitiesMeta: cursorClientCapabilitiesMeta, + resolveModelId: (selection) => cursorBaseModelId(selection.model), + } + : {}), ...(copilotRouting === undefined ? {} : { diff --git a/packages/provider-acp-registry/src/server/cursorAcp.ts b/packages/provider-acp-registry/src/server/cursorAcp.ts new file mode 100644 index 0000000000000..86172ff6e0ec6 --- /dev/null +++ b/packages/provider-acp-registry/src/server/cursorAcp.ts @@ -0,0 +1,20 @@ +import type { AcpRegistrySettings } from "../settings.ts"; + +// Without this flag Cursor lists one fixed preset per model, such as +// `claude-opus-5-5[context=300k,effort=medium,fast=false]`. With it Cursor lists +// plain model ids and reports effort, context and fast as config options. +export const cursorClientCapabilitiesMeta = { parameterizedModelPicker: true }; + +// Local installs launch the `cursor-agent` binary directly with no registry id. +export function isCursorAcpAgent(settings: AcpRegistrySettings): boolean { + return ( + (settings.source !== "local" && settings.agentId === "cursor") || + (settings.source === "local" && + /^cursor-agent(\.exe|\.cmd)?$/i.test(settings.commandPath.split(/[\\/]/).pop() ?? "")) + ); +} + +// Threads saved before the flag keep a preset id; open its plain model instead. +export function cursorBaseModelId(model: string): string { + return model.replace(/\[[^\]]*\]$/, ""); +} diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index ba50b57461fa6..81c52f20058de 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -34,6 +34,7 @@ import { flattenSelectChoices, } from "@t3tools/provider-acp/server/sessionConfig"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; +import { cursorClientCapabilitiesMeta, isCursorAcpAgent } from "./cursorAcp.ts"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const MAX_AUTH_METHODS = 32; @@ -348,6 +349,8 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo elicitation: { url: {} }, fs: { readTextFile: false, writeTextFile: false }, terminal: false, + // The catalog has to list the same model ids the sessions will offer. + ...(isCursorAcpAgent(input.settings) ? { _meta: cursorClientCapabilitiesMeta } : {}), }, clientInfo: { name: "t3-code-provider-test", version: "0.0.0" }, authenticateOnAuthRequired: false, diff --git a/packages/provider-acp-registry/src/testing.ts b/packages/provider-acp-registry/src/testing.ts index 11bb5e41d9450..4cc790443e5cc 100644 --- a/packages/provider-acp-registry/src/testing.ts +++ b/packages/provider-acp-registry/src/testing.ts @@ -18,6 +18,7 @@ export { logoutAcpRegistry, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, + probeAcpRegistryConfiguration, type AcpRegistryAvailableCommands, type AcpRegistryLiveConfiguration, } from "./server/probe.ts"; From 391e70fca5f8637940ec97f749ab0b50aeb2b374 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 13:35:59 +0000 Subject: [PATCH 2/5] fix(acp-registry): preserve saved Cursor preset options --- .../Adapters/AcpRegistryAdapterV2.test.ts | 25 +++++++++++++++++++ .../src/server/adapter.ts | 8 ++++-- .../src/server/cursorAcp.ts | 20 ++++++++++++--- packages/provider-acp/src/server/adapter.ts | 2 ++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index d95f2490451fc..6387bb863bf21 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1069,6 +1069,31 @@ describe("AcpRegistryAdapterV2", () => { ); const saved = yield* openCursor({ instanceId, model: "composer-2[fast=true]" }); assert.equal(saved?.currentModelId, "composer-2"); + assert.deepInclude( + saved?.configOptions.find((option) => option.id === "fast"), + { currentValue: "true" }, + ); + const preset = yield* openCursor({ instanceId, model: "gpt-5.4[effort=high,fast=false]" }); + assert.deepInclude( + preset?.configOptions.find((option) => option.id === "reasoning"), + { currentValue: "high" }, + ); + const legacy = yield* openCursor({ + instanceId, + model: "gpt-5.4[effort=high,context=1m,fast=true]", + options: [{ id: "reasoning", value: "extra-high" }], + }); + assert.equal(legacy?.currentModelId, "gpt-5.4"); + for (const [id, value] of [ + ["reasoning", "extra-high"], + ["context", "1m"], + ["fast", "true"], + ]) { + assert.deepInclude( + legacy?.configOptions.find((option) => option.id === id), + { currentValue: value }, + ); + } }).pipe(Effect.provide(layerTest), Effect.scoped), ); diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 3f4468f68b76b..dfbff1b483e81 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -14,7 +14,11 @@ import { extractCopilotSubagentUpdate, makeCopilotSubagentRouting, } from "./copilotAcp.ts"; -import { cursorBaseModelId, cursorClientCapabilitiesMeta, isCursorAcpAgent } from "./cursorAcp.ts"; +import { + normalizeCursorModelSelection, + cursorClientCapabilitiesMeta, + isCursorAcpAgent, +} from "./cursorAcp.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; import { readCustomModelEntries } from "@t3tools/shared/model"; import { AcpRegistrySettings } from "../settings.ts"; @@ -242,7 +246,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu ...(isCursorAcpAgent(options.settings) ? { clientCapabilitiesMeta: cursorClientCapabilitiesMeta, - resolveModelId: (selection) => cursorBaseModelId(selection.model), + normalizeModelSelection: normalizeCursorModelSelection, } : {}), ...(copilotRouting === undefined diff --git a/packages/provider-acp-registry/src/server/cursorAcp.ts b/packages/provider-acp-registry/src/server/cursorAcp.ts index 86172ff6e0ec6..c42adbb5aaef7 100644 --- a/packages/provider-acp-registry/src/server/cursorAcp.ts +++ b/packages/provider-acp-registry/src/server/cursorAcp.ts @@ -1,3 +1,4 @@ +import type { ModelSelection } from "@t3tools/contracts"; import type { AcpRegistrySettings } from "../settings.ts"; // Without this flag Cursor lists one fixed preset per model, such as @@ -14,7 +15,20 @@ export function isCursorAcpAgent(settings: AcpRegistrySettings): boolean { ); } -// Threads saved before the flag keep a preset id; open its plain model instead. -export function cursorBaseModelId(model: string): string { - return model.replace(/\[[^\]]*\]$/, ""); +// Preserve preset values when opening threads saved before parameterized negotiation. +export function normalizeCursorModelSelection(selection: ModelSelection): ModelSelection { + const preset = /^(.*)\[([^\]]*)\]$/.exec(selection.model); + if (preset === null) return selection; + const explicit = selection.options ?? []; + const options = preset[2]!.split(",").flatMap((entry) => { + const [key, value] = entry.split("="); + const id = key === "effort" ? "reasoning" : key; + return id !== undefined && + ["reasoning", "context", "fast"].includes(id) && + value && + !explicit.some((option) => option.id === id) + ? [{ id, value }] + : []; + }); + return { ...selection, model: preset[1]!, options: [...options, ...explicit] }; } diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b3ce13af0a705..29ed4609a4db7 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -269,6 +269,7 @@ export interface AcpAdapterV2Flavor { ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope >; readonly customModels?: ReadonlyArray; + readonly normalizeModelSelection?: (selection: ModelSelection) => ModelSelection; readonly resolveModelId?: (selection: ModelSelection) => string | undefined; readonly getModelContextWindow?: ( selection: ModelSelection, @@ -6578,6 +6579,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) { yield* Ref.set(activeSelection, null); + modelSelection = flavor.normalizeModelSelection?.(modelSelection) ?? modelSelection; const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; // Invalid params is the agent refusing the value, and the runtime // raises the same code when the session's model option does not offer From 61bf78422c3822b093057af0eebd4ae243b64ab7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 08:06:35 +0000 Subject: [PATCH 3/5] fix(acp-registry): retain native saved Cursor selections --- apps/server/scripts/acp-mock-agent.ts | 9 ++- .../Adapters/AcpRegistryAdapterV2.test.ts | 28 +++++++- .../src/provider/AcpRegistryProbe.test.ts | 1 + .../components/chat/composerProviderState.tsx | 2 +- apps/web/src/modelSelection.test.ts | 64 +++++++++++-------- apps/web/src/modelSelection.ts | 7 +- .../src/server/cursorAcp.ts | 5 +- 7 files changed, 78 insertions(+), 38 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a10f9855cbbbf..8652b09fc893b 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -153,6 +153,7 @@ function configOptions(): ReadonlyArray { { value: "composer-2", name: "Composer 2" }, { value: "gpt-5.4", name: "GPT-5.4" }, { value: "claude-opus-4-6", name: "Opus 4.6" }, + { value: "claude-sonnet-4-6", name: "Sonnet 4.6" }, ], }, ]; @@ -214,10 +215,11 @@ function configOptions(): ReadonlyArray { }, ]; case "claude-opus-4-6": + case "claude-sonnet-4-6": return [ ...baseOptions, { - configId: "reasoning", + configId: currentModelId === "claude-opus-4-6" ? "effort" : "reasoning_effort", name: "Reasoning", category: "thought_level", type: "select", @@ -750,7 +752,10 @@ const program = Effect.gen(function* () { if (request.configId === "agent" && typeof request.value === "string") { currentAgent = request.value; } - if (request.configId === "reasoning" && typeof request.value === "string") { + if ( + ["reasoning", "effort", "reasoning_effort"].includes(request.configId) && + typeof request.value === "string" + ) { currentReasoning = request.value; } if (request.configId === "context" && typeof request.value === "string") { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 3e2f21f89f63d..4d8e09ecd6bcf 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1073,14 +1073,38 @@ describe("AcpRegistryAdapterV2", () => { saved?.configOptions.find((option) => option.id === "fast"), { currentValue: "true" }, ); - const preset = yield* openCursor({ instanceId, model: "gpt-5.4[effort=high,fast=false]" }); + const preset = yield* openCursor({ instanceId, model: "gpt-5.4[reasoning=high,fast=false]" }); assert.deepInclude( preset?.configOptions.find((option) => option.id === "reasoning"), { currentValue: "high" }, ); + for (const [model, id] of [ + ["claude-opus-4-6", "effort"], + ["claude-sonnet-4-6", "reasoning_effort"], + ]) { + const native = yield* openCursor({ instanceId, model: `${model}[${id}=high]` }); + assert.equal(native?.currentModelId, model); + assert.deepInclude( + native?.configOptions.find((option) => option.id === id), + { + currentValue: "high", + }, + ); + const overridden = yield* openCursor({ + instanceId, + model: `${model}[${id}=high]`, + options: [{ id: id!, value: "low" }], + }); + assert.deepInclude( + overridden?.configOptions.find((option) => option.id === id), + { + currentValue: "low", + }, + ); + } const legacy = yield* openCursor({ instanceId, - model: "gpt-5.4[effort=high,context=1m,fast=true]", + model: "gpt-5.4[reasoning=high,context=1m,fast=true]", options: [{ id: "reasoning", value: "extra-high" }], }); assert.equal(legacy?.currentModelId, "gpt-5.4"); diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 35343fc29fc48..254e5543d7dfa 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -403,6 +403,7 @@ describe("ACP Registry probe", () => { "composer-2", "gpt-5.4", "claude-opus-4-6", + "claude-sonnet-4-6", ]); expect(yield* probedModels("mock-agent")).toContain( "gpt-5.3-codex[reasoning=medium,fast=false]", diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index b2ba639230eb4..a8dbb353f32fc 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -115,7 +115,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com promptInjectionState = "none", planModeEnabled, } = input; - if (provider === "opencode") { + if (provider === "opencode" || provider === "acpRegistry") { const normalizedModel = normalizeModelSlug(model, provider); const modelIsInCatalog = models.some((candidate) => candidate.slug === normalizedModel); if (!modelIsInCatalog) { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 05a7e76b6b25b..740e3ecd2a78f 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -249,6 +249,11 @@ describe("instance-scoped model selection", () => { availableModel: "opencode/big-pickle", missingModel: "opencode/kimi-k3", }, + { + driverName: "acpRegistry", + availableModel: "composer-2", + missingModel: "composer-2[fast=true]", + }, { driverName: "antigravity", availableModel: "gemini-3.1-pro", @@ -536,35 +541,38 @@ describe("instance-scoped model selection", () => { ).toEqual([]); }); - it("preserves saved options through dispatch when the model is absent from the catalog", () => { - const instanceId = ProviderInstanceId.make("opencode"); - const driver = ProviderDriverKind.make("opencode"); - const providers = [provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] })]; - const saved = createModelSelection(instanceId, "opencode/kimi-k3", [ - { id: "variant", value: "max" }, - { id: "agent", value: "build" }, - ]); - const state = deriveEffectiveComposerModelState({ - draft: null, - providers, - selectedProvider: driver, - selectedInstanceId: instanceId, - threadModelSelection: saved, - projectModelSelection: null, - settings: settingsWithProviderInstances(), - }); - const dispatch = getComposerProviderState({ - provider: driver, - model: state.selectedModel, - models: providers[0]!.models, - modelOptions: state.modelOptions?.[instanceId], - planModeEnabled: false, - }); + it.each([ + ["opencode", "opencode/big-pickle", "opencode/kimi-k3", "variant", "max"], + ["acpRegistry", "composer-2", "composer-2[fast=true]", "fast", "false"], + ])( + "preserves saved %s options through dispatch when the model is absent from the catalog", + (driverName, availableModel, missingModel, id, value) => { + const instanceId = ProviderInstanceId.make(driverName); + const driver = ProviderDriverKind.make(driverName); + const providers = [provider({ provider: driver, instanceId, models: [availableModel] })]; + const saved = createModelSelection(instanceId, missingModel, [{ id, value }]); + const state = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: saved, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + const dispatch = getComposerProviderState({ + provider: driver, + model: state.selectedModel, + models: providers[0]!.models, + modelOptions: state.modelOptions?.[instanceId], + planModeEnabled: false, + }); - expect( - createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), - ).toEqual(saved); - }); + expect( + createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), + ).toEqual(saved); + }, + ); it("keeps a custom-instance draft model while dropping unsupported options", () => { const instanceId = ProviderInstanceId.make("claude_openrouter"); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index db52028c397e0..d51ab2389992c 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -74,7 +74,8 @@ function appendUnavailableDynamicModelSelection( selectedModel: string | null | undefined, hiddenModels: ReadonlyArray, ): AppModelOption[] { - if (provider !== "opencode" && provider !== "antigravity") return options; + if (provider !== "opencode" && provider !== "antigravity" && provider !== "acpRegistry") + return options; const slug = normalizeCustomModelSlug(selectedModel); if (!slug) return options; if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; @@ -276,7 +277,9 @@ export function resolveAppModelSelectionForInstance( } if ( resolutionOptions?.preserveUnavailableSelection && - (entry.driverKind === "opencode" || entry.driverKind === "antigravity") + (entry.driverKind === "opencode" || + entry.driverKind === "antigravity" || + entry.driverKind === "acpRegistry") ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; diff --git a/packages/provider-acp-registry/src/server/cursorAcp.ts b/packages/provider-acp-registry/src/server/cursorAcp.ts index c42adbb5aaef7..2ea262507b959 100644 --- a/packages/provider-acp-registry/src/server/cursorAcp.ts +++ b/packages/provider-acp-registry/src/server/cursorAcp.ts @@ -21,10 +21,9 @@ export function normalizeCursorModelSelection(selection: ModelSelection): ModelS if (preset === null) return selection; const explicit = selection.options ?? []; const options = preset[2]!.split(",").flatMap((entry) => { - const [key, value] = entry.split("="); - const id = key === "effort" ? "reasoning" : key; + const [id, value] = entry.split("="); return id !== undefined && - ["reasoning", "context", "fast"].includes(id) && + ["reasoning", "effort", "reasoning_effort", "context", "fast"].includes(id) && value && !explicit.some((option) => option.id === id) ? [{ id, value }] From 02f104daf59096f6cd3bd77bf419cf9dd45b9566 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 08:30:06 +0000 Subject: [PATCH 4/5] fix(web): limit saved ACP presets to Cursor --- apps/web/src/components/chat/TraitsPicker.tsx | 2 +- apps/web/src/modelSelection.test.ts | 65 ++++++++++--------- apps/web/src/modelSelection.ts | 25 ++++++- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 79a29d293dc49..940295a11ac84 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -123,7 +123,7 @@ function getSelectedTraits( ) { const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const modelIsUnavailable = - provider === "opencode" && + (provider === "opencode" || provider === "acpRegistry") && !models.some((candidate) => candidate.slug === normalizeModelSlug(model, provider)); const descriptors = modelIsUnavailable ? buildUnavailableModelOptionDescriptors( diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 740e3ecd2a78f..c1849b317acde 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -56,6 +56,10 @@ function settingsWithProviderInstances(): UnifiedSettings { return { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { + [ProviderInstanceId.make("acpRegistry")]: { + driver: ProviderDriverKind.make("acpRegistry"), + config: { agentId: "cursor" }, + }, [ProviderInstanceId.make("claudeAgent")]: { driver: ProviderDriverKind.make("claudeAgent"), config: { customModels: [] }, @@ -334,37 +338,40 @@ describe("instance-scoped model selection", () => { }); }); - it("falls back from an explicit non-OpenCode draft with a missing model", () => { - const instanceId = ProviderInstanceId.make("codex"); - const driver = ProviderDriverKind.make("codex"); - const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })]; - const state = deriveEffectiveComposerModelState({ - draft: { - activeProvider: instanceId, - modelSelectionByProvider: { - [instanceId]: createModelSelection(instanceId, "gpt-missing", [ - { id: "effort", value: "max" }, - ]), + it.each(["codex", "acpRegistry"])( + "falls back from an explicit %s draft with a missing model", + (driverName) => { + const instanceId = ProviderInstanceId.make(driverName); + const driver = ProviderDriverKind.make(driverName); + const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })]; + const state = deriveEffectiveComposerModelState({ + draft: { + activeProvider: instanceId, + modelSelectionByProvider: { + [instanceId]: createModelSelection(instanceId, "gpt-missing", [ + { id: "effort", value: "max" }, + ]), + }, }, - }, - providers, - selectedProvider: driver, - selectedInstanceId: instanceId, - threadModelSelection: null, - projectModelSelection: null, - settings: settingsWithProviderInstances(), - }); - const dispatch = getComposerProviderState({ - provider: driver, - model: state.selectedModel, - models: providers[0]!.models, - modelOptions: state.modelOptions?.[instanceId], - planModeEnabled: false, - }); + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: null, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + const dispatch = getComposerProviderState({ + provider: driver, + model: state.selectedModel, + models: providers[0]!.models, + modelOptions: state.modelOptions?.[instanceId], + planModeEnabled: false, + }); - expect(state.selectedModel).toBe("gpt-5.6-sol"); - expect(dispatch.modelOptionsForDispatch).toBeUndefined(); - }); + expect(state.selectedModel).toBe("gpt-5.6-sol"); + expect(dispatch.modelOptionsForDispatch).toBeUndefined(); + }, + ); it("preserves an explicit draft OpenCode selection while the catalog is empty", () => { const instanceId = ProviderInstanceId.make("opencode_work"); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index d51ab2389992c..85c074058534d 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -19,6 +19,7 @@ import { getComposerProviderState } from "./components/chat/composerProviderStat import { UnifiedSettings } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; +import * as Predicate from "effect/Predicate"; import { getDefaultServerModel, getProviderModels, @@ -67,15 +68,32 @@ export interface AppModelOption { isUnavailable?: boolean; } +function isSavedCursorPreset( + settings: UnifiedSettings, + entry: ProviderInstanceEntry, + selectedModel: string | null | undefined, +): boolean { + if (entry.driverKind !== "acpRegistry") return false; + const config = settings.providerInstances[entry.instanceId]?.config; + if (!Predicate.isObject(config)) return false; + const cursor = + config?.source === "local" + ? typeof config.commandPath === "string" && + /^cursor-agent(\.exe|\.cmd)?$/i.test(config.commandPath.split(/[\\/]/).pop() ?? "") + : config?.agentId === "cursor"; + const preset = /^(.*)\[[^\]]*\]$/.exec(selectedModel ?? ""); + return cursor && preset !== null && entry.models.some((model) => model.slug === preset[1]); +} + function appendUnavailableDynamicModelSelection( options: AppModelOption[], rawModels: ReadonlyArray, provider: ProviderDriverKind, selectedModel: string | null | undefined, hiddenModels: ReadonlyArray, + savedCursorPreset = false, ): AppModelOption[] { - if (provider !== "opencode" && provider !== "antigravity" && provider !== "acpRegistry") - return options; + if (provider !== "opencode" && provider !== "antigravity" && !savedCursorPreset) return options; const slug = normalizeCustomModelSlug(selectedModel); if (!slug) return options; if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; @@ -238,6 +256,7 @@ export function getAppModelOptionsForInstance( entry.driverKind, selectedModel, preferences.hiddenModels, + isSavedCursorPreset(settings, entry, selectedModel), ); } @@ -279,7 +298,7 @@ export function resolveAppModelSelectionForInstance( resolutionOptions?.preserveUnavailableSelection && (entry.driverKind === "opencode" || entry.driverKind === "antigravity" || - entry.driverKind === "acpRegistry") + isSavedCursorPreset(settings, entry, selectedModel)) ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; From e7e7a42c6aa2b90623942a64be03f7f4412339cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:00:09 +0000 Subject: [PATCH 5/5] test(acp-registry): trim redundant Cursor preset cases AI-assisted; verified with the tests and tools listed above. --- .../Adapters/AcpRegistryAdapterV2.test.ts | 26 +------------------ apps/web/src/modelSelection.ts | 4 +-- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 0793124ee99e6..6eee03483902b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1311,17 +1311,6 @@ describe("AcpRegistryAdapterV2", () => { picked?.configOptions.find((option) => option.id === "reasoning"), { currentValue: "extra-high" }, ); - const saved = yield* openCursor({ instanceId, model: "composer-2[fast=true]" }); - assert.equal(saved?.currentModelId, "composer-2"); - assert.deepInclude( - saved?.configOptions.find((option) => option.id === "fast"), - { currentValue: "true" }, - ); - const preset = yield* openCursor({ instanceId, model: "gpt-5.4[reasoning=high,fast=false]" }); - assert.deepInclude( - preset?.configOptions.find((option) => option.id === "reasoning"), - { currentValue: "high" }, - ); for (const [model, id] of [ ["claude-opus-4-6", "effort"], ["claude-sonnet-4-6", "reasoning_effort"], @@ -1330,20 +1319,7 @@ describe("AcpRegistryAdapterV2", () => { assert.equal(native?.currentModelId, model); assert.deepInclude( native?.configOptions.find((option) => option.id === id), - { - currentValue: "high", - }, - ); - const overridden = yield* openCursor({ - instanceId, - model: `${model}[${id}=high]`, - options: [{ id: id!, value: "low" }], - }); - assert.deepInclude( - overridden?.configOptions.find((option) => option.id === id), - { - currentValue: "low", - }, + { currentValue: "high" }, ); } const legacy = yield* openCursor({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 6c7d6b9caaa8f..ed2bdf0870fb5 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -83,10 +83,10 @@ function isSavedCursorPreset( const config = settings.providerInstances[entry.instanceId]?.config; if (!Predicate.isObject(config)) return false; const cursor = - config?.source === "local" + config.source === "local" ? typeof config.commandPath === "string" && /^cursor-agent(\.exe|\.cmd)?$/i.test(config.commandPath.split(/[\\/]/).pop() ?? "") - : config?.agentId === "cursor"; + : config.agentId === "cursor"; const preset = /^(.*)\[[^\]]*\]$/.exec(selectedModel ?? ""); return cursor && preset !== null && entry.models.some((model) => model.slug === preset[1]); }