diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a97aab9cdf2b1..9e88fbc88a9c9 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -193,6 +193,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" }, ], }, ]; @@ -254,10 +255,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", @@ -833,7 +835,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 97fb6e636054f..5dd2a6b12627c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1235,6 +1235,124 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + // 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; + }), + }), + ), + ), + ); + const threadId = ThreadId.make("thread-acp-registry-cursor"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + yield* Effect.gen(function* () { + const runtime = yield* adapter.openSession({ + threadId: ThreadId.make("thread-acp-registry-cursor"), + providerSessionId: ProviderSessionId.make("provider-session-acp-registry-cursor"), + modelSelection: stored, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: stored, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal: 1, + modelSelection: stored, + }), + ); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runForEach((event) => + Effect.sync(() => { + if (event.type === "turn.terminal") { + assert.equal(event.status, "completed", event.failure?.message); + } + }), + ), + ); + }).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" }, + ); + 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 legacy = yield* openCursor({ + instanceId, + model: "gpt-5.4[reasoning=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), + ); + // regression: #26695 cancelling a Copilot prompt kills its background agents it.effect("never steers Copilot by cancelling its prompt", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 30915b31d474f..e74671bfc4491 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -413,6 +413,45 @@ 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", + "claude-sonnet-4-6", + ]); + expect(yield* probedModels("mock-agent")).toContain( + "gpt-5.3-codex[reasoning=medium,fast=false]", + ); + }).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); + }); + it.live.each(["succeeds", "fails"] as const)( "deletes its health-probe session when deletion is advertised and %s", (deleteOutcome) => diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 4cff71c95ca57..67d6887723bbd 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -122,7 +122,7 @@ function getSelectedTraits( ) { const caps = getProviderModelCapabilities(models, model, provider, includePlanAgent); const modelIsUnavailable = - (provider === "opencode" || provider === "cursor") && + (provider === "opencode" || provider === "acpRegistry" || provider === "cursor") && !models.some((candidate) => candidate.slug === normalizeModelSlug(model, provider)); const descriptors = modelIsUnavailable ? buildUnavailableModelOptionDescriptors( diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 06b403b082913..7aaf664b869d1 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -86,7 +86,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com promptInjectionState = "none", includePlanAgent, } = input; - if (provider === "opencode" || provider === "cursor") { + if (provider === "opencode" || provider === "acpRegistry" || provider === "cursor") { 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 62a74204dd179..2a057662bfaa0 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -70,6 +70,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: [] }, @@ -399,6 +403,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", @@ -484,37 +493,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({ - threadStarted: false, - 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({ + threadStarted: false, + 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], - }); + 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], + }); - 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("sends a Cursor thread's stored model and options when the catalog lacks them", () => { const instanceId = ProviderInstanceId.make("cursor"); @@ -723,35 +735,46 @@ 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({ - threadStarted: false, - 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], - }); + it.each([ + [ + "opencode", + "opencode/big-pickle", + "opencode/kimi-k3", + [ + { id: "variant", value: "max" }, + { id: "agent", value: "build" }, + ], + ], + ["acpRegistry", "composer-2", "composer-2[fast=true]", [{ id: "fast", value: "false" }]], + ])( + "preserves saved %s options through dispatch when the model is absent from the catalog", + (driverName, availableModel, missingModel, options) => { + const instanceId = ProviderInstanceId.make(driverName); + const driver = ProviderDriverKind.make(driverName); + const providers = [provider({ provider: driver, instanceId, models: [availableModel] })]; + const saved = createModelSelection(instanceId, missingModel, options); + const state = deriveEffectiveComposerModelState({ + threadStarted: false, + 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], + }); - expect( - createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), - ).toEqual(saved); - }); + expect( + createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), + ).toEqual(saved); + }, + ); describe("composer model option precedence", () => { const instanceId = ProviderInstanceId.make("claudeAgent"); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 3f566afe19223..03fe0ef0e8a65 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -23,6 +23,7 @@ import { resolveModelPreferences } from "@t3tools/shared/serverSettings"; import * as Arr from "effect/Array"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; +import * as Predicate from "effect/Predicate"; import { getDefaultServerModel, getProviderModels, @@ -73,14 +74,37 @@ 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 !== "cursor") { + if ( + provider !== "opencode" && + provider !== "antigravity" && + provider !== "cursor" && + !savedCursorPreset + ) { return options; } const slug = normalizeCustomModelSlug(selectedModel); @@ -245,6 +269,7 @@ export function getAppModelOptionsForInstance( entry.driverKind, selectedModel, preferences.hiddenModels, + isSavedCursorPreset(settings, entry, selectedModel), ); } @@ -286,7 +311,8 @@ export function resolveAppModelSelectionForInstance( resolutionOptions?.preserveUnavailableSelection && (entry.driverKind === "opencode" || entry.driverKind === "antigravity" || - entry.driverKind === "cursor") + entry.driverKind === "cursor" || + isSavedCursorPreset(settings, entry, selectedModel)) ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 360ba35e8cc5c..8849630209675 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -18,6 +18,11 @@ import { isCopilotLostSessionError, makeCopilotSubagentRouting, } from "./copilotAcp.ts"; +import { + normalizeCursorModelSelection, + cursorClientCapabilitiesMeta, + isCursorAcpAgent, +} from "./cursorAcp.ts"; import { prefersHttpMcpTransport } from "./mcpTransport.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; import { readCustomModelEntries } from "@t3tools/shared/model"; @@ -272,6 +277,12 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu answerablePermissionOptions: devinAnswerablePermissionOptions, } : {}), + ...(isCursorAcpAgent(options.settings) + ? { + clientCapabilitiesMeta: cursorClientCapabilitiesMeta, + normalizeModelSelection: normalizeCursorModelSelection, + } + : {}), ...(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..2ea262507b959 --- /dev/null +++ b/packages/provider-acp-registry/src/server/cursorAcp.ts @@ -0,0 +1,33 @@ +import type { ModelSelection } from "@t3tools/contracts"; +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() ?? "")) + ); +} + +// 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 [id, value] = entry.split("="); + return id !== undefined && + ["reasoning", "effort", "reasoning_effort", "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-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index 662f28783ca45..75c21ba641fe2 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -35,6 +35,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; @@ -400,6 +401,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/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index dc12bd7aa1e14..00c416b25883c 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -419,6 +419,7 @@ export interface AcpAdapterV2Flavor { ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope >; readonly customModels?: ReadonlyArray; + readonly normalizeModelSelection?: (selection: ModelSelection) => ModelSelection; /** The agent may run another model than session/new reports, so the model is always written. */ readonly alwaysSetModel?: boolean; /** Values the client shows for options a new session's selection leaves unset. */ @@ -7572,6 +7573,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const validateModelSelection = Effect.fn("AcpAdapterV2.validateModelSelection")(function* ( modelSelection: ModelSelection, ) { + modelSelection = flavor.normalizeModelSelection?.(modelSelection) ?? modelSelection; const model = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; if ( flavor.applyModelSelection !== undefined || @@ -7604,6 +7606,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( unsetOptionDefaults: NonNullable = [], ) { yield* Ref.set(activeSelection, null); + modelSelection = flavor.normalizeModelSelection?.(modelSelection) ?? modelSelection; yield* validateModelSelection(modelSelection); const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; // Invalid params is the agent refusing the value, and the runtime