diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e704b8d8a25..0cdd7c1aff3 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -183,6 +183,35 @@ function configOptions(): ReadonlyArray { ]; } +function modelConfigOptionsFor(modelId: string): ReadonlyArray { + const previousModelId = currentModelId; + try { + currentModelId = modelId; + return configOptions().filter( + (option) => option.category !== "mode" && option.category !== "model", + ); + } finally { + currentModelId = previousModelId; + } +} + +function availableModels(): ReadonlyArray<{ + readonly value: string; + readonly name: string; + readonly configOptions: ReadonlyArray; +}> { + return [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "gpt-5.4", name: "GPT-5.4" }, + { value: "claude-opus-4-6", name: "Opus 4.6" }, + ].map((model) => ({ + value: model.value, + name: model.name, + configOptions: modelConfigOptionsFor(model.value), + })); +} + const availableModes: ReadonlyArray = [ { id: "ask", @@ -517,6 +546,12 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { + if (method === "cursor/list_available_models") { + return Effect.succeed({ + models: availableModels(), + }); + } + if (method !== "session/mode/set") { return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); } diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 46347091b4c..ba532864c45 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -1,10 +1,9 @@ /** * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime. * - * Cursor exposes an ACP-based CLI. The driver is still a plain value, but - * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` - * hook to run the slow ACP model-capability probe in the background without - * blocking the initial `ready`-state publish. + * Cursor exposes an ACP-based CLI. Model catalog and capability refreshes + * happen during the managed provider status check via Cursor's + * `list_available_models` extension method. * * Text generation is supported via the ACP runtime — `makeCursorTextGeneration` * drives `runtime.prompt` with a structured-output schema and collects the @@ -139,20 +138,17 @@ export const CursorDriver: ProviderDriver = { initialSnapshot: (settings) => buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), checkProvider, - // Preserve the background ACP model-capability probe that used to - // live on `CursorProviderLive`. Only fires when the snapshot reports - // an authenticated, enabled provider with at least one non-custom - // model whose capabilities haven't been captured yet. + // Model catalog and capabilities come exclusively from Cursor's + // list_available_models extension method during provider checks. enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichCursorSnapshot({ settings, - environment: processEnv, snapshot: currentSnapshot, maintenanceCapabilities, publishSnapshot, stampIdentity, httpClient, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + }), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 90b36e89004..c4fd6090ba3 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -6,15 +6,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { describe, expect, it } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; -import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; +import type { CursorSettings } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { buildCursorProviderSnapshot, buildCursorCapabilitiesFromConfigOptions, - buildCursorDiscoveredModelsFromConfigOptions, checkCursorProviderStatus, - discoverCursorModelCapabilitiesViaAcp, discoverCursorModelsViaAcp, getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, @@ -295,48 +293,6 @@ const parameterizedClaudeModelOptionConfigOptions = [ }, ] satisfies ReadonlyArray; -const sessionNewCursorConfigOptions = [ - { - type: "select", - currentValue: "agent", - options: [ - { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" }, - ], - category: "mode", - id: "mode", - name: "Mode", - description: "Controls how the agent executes tasks", - }, - { - type: "select", - currentValue: "composer-2", - options: [ - { name: "Auto", value: "default" }, - { name: "Composer 2", value: "composer-2" }, - { name: "GPT-5.4", value: "gpt-5.4" }, - { name: "Sonnet 4.6", value: "claude-sonnet-4-6" }, - { name: "Opus 4.6", value: "claude-opus-4-6" }, - { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" }, - ], - category: "model", - id: "model", - name: "Model", - description: "Controls which model is used for responses", - }, - { - type: "select", - currentValue: "true", - options: [ - { name: "Off", value: "false" }, - { name: "Fast", value: "true" }, - ], - category: "model_config", - id: "fast", - name: "Fast", - description: "Faster speeds.", - }, -] satisfies ReadonlyArray; - const baseCursorSettings: CursorSettings = { enabled: true, binaryPath: "agent", @@ -344,8 +300,6 @@ const baseCursorSettings: CursorSettings = { customModels: [], }; -const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); - describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { expect( @@ -462,51 +416,6 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); }); -describe("buildCursorDiscoveredModelsFromConfigOptions", () => { - it("publishes ACP model choices immediately from session/new config options", () => { - expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([ - { - slug: "default", - name: "Auto", - isCustom: false, - capabilities: emptyCapabilities, - }, - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], - }), - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: emptyCapabilities, - }, - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: emptyCapabilities, - }, - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: emptyCapabilities, - }, - { - slug: "gpt-5.3-codex-spark", - name: "Codex 5.3 Spark", - isCustom: false, - capabilities: emptyCapabilities, - }, - ]); - }); -}); - describe("checkCursorProviderStatus", () => { it("passes the injected environment to ACP model discovery", async () => { const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture()); @@ -576,47 +485,6 @@ describe("discoverCursorModelsViaAcp", () => { }); }); -describe("discoverCursorModelCapabilitiesViaAcp", () => { - it("closes all ACP probe runtimes after capability enrichment completes", async () => { - const { exitLogPath, wrapperPath } = await runNode( - makeExitLogFixture("cursor-capabilities-exit-log-"), - ); - const existingModels: ReadonlyArray = [ - { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities }, - { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities }, - { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities }, - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: emptyCapabilities, - }, - ]; - - const models = await Effect.runPromise( - discoverCursorModelCapabilitiesViaAcp( - { - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }, - existingModels, - ).pipe(Effect.provide(NodeServices.layer)), - ); - - expect(models.map((model) => model.slug)).toEqual([ - "default", - "composer-2", - "gpt-5.4", - "claude-opus-4-6", - ]); - - const exitLog = await runNode(waitForFileContent(exitLogPath)); - expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4); - }); -}); - describe("parseCursorAboutOutput", () => { it("parses json about output and forwards subscription metadata", () => { expect( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 88a963f693b..064fa8dfe36 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -19,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -42,6 +43,7 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; +import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_PRESENTATION = { @@ -54,8 +56,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; -const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; -const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { _meta: { @@ -154,12 +154,6 @@ function normalizeCursorReasoningValue(value: string | null | undefined): string } } -function findCursorModelConfigOption( - configOptions: ReadonlyArray, -): EffectAcpSchema.SessionConfigOption | undefined { - return configOptions.find((option) => option.category === "model"); -} - function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string { return option.category?.trim().toLowerCase() ?? ""; } @@ -373,36 +367,25 @@ function buildCursorDiscoveredModels( }); } -function hasCursorModelCapabilities(model: Pick): boolean { - return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; -} - -export function buildCursorDiscoveredModelsFromConfigOptions( - configOptions: ReadonlyArray | null | undefined, +function buildCursorDiscoveredModelsFromAvailableModelsResponse( + response: typeof CursorListAvailableModelsResponse.Type, ): ReadonlyArray { - if (!configOptions || configOptions.length === 0) { - return []; - } - - const modelOption = findCursorModelConfigOption(configOptions); - const modelChoices = flattenSessionConfigSelectOptions(modelOption); - if (!modelOption || modelChoices.length === 0) { - return []; - } - - const currentModelValue = - modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - const currentModelCapabilities = buildCursorCapabilitiesFromConfigOptions(configOptions); - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: - currentModelValue === modelChoice.value.trim() - ? currentModelCapabilities - : EMPTY_CAPABILITIES, - })), + response.models.flatMap((model) => { + const slug = model.value.trim(); + const name = model.name.trim(); + if (!slug || !name) { + return []; + } + + return [ + { + slug, + name, + capabilities: buildCursorCapabilitiesFromConfigOptions(model.configOptions), + }, + ]; + }), ); } @@ -554,140 +537,28 @@ export function resolveCursorAcpConfigUpdates( return updates; } -export const discoverCursorModelsViaAcp = ( +const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, environment: NodeJS.ProcessEnv = process.env, ) => withCursorAcpProbeRuntime( cursorSettings, (acp) => - Effect.map(acp.start(), (started) => - buildCursorDiscoveredModelsFromConfigOptions( - started.sessionSetupResult.configOptions ?? [], - ), - ), + Effect.gen(function* () { + yield* acp.start(); + const response = yield* acp.request("cursor/list_available_models", {}); + const decoded = yield* Schema.decodeUnknownEffect(CursorListAvailableModelsResponse)( + response, + ); + return buildCursorDiscoveredModelsFromAvailableModelsResponse(decoded); + }), environment, ); -export const discoverCursorModelCapabilitiesViaAcp = ( +export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, - existingModels: ReadonlyArray, environment: NodeJS.ProcessEnv = process.env, -) => - withCursorAcpProbeRuntime( - cursorSettings, - (acp) => - Effect.gen(function* () { - const started = yield* acp.start(); - const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; - const modelOption = findCursorModelConfigOption(initialConfigOptions); - const modelChoices = flattenSessionConfigSelectOptions(modelOption); - if (!modelOption || modelChoices.length === 0) { - return []; - } - - const currentModelValue = - modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - const capabilitiesBySlug = new Map(); - if (currentModelValue) { - capabilitiesBySlug.set( - currentModelValue, - buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), - ); - } - - const targetModelSlugs = new Set(); - for (const model of existingModels) { - if (!model.isCustom && !hasCursorModelCapabilities(model)) { - targetModelSlugs.add(model.slug); - } - } - if (targetModelSlugs.size === 0) { - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, - })), - ); - } - - const probedCapabilities = yield* Effect.forEach( - modelChoices, - (modelChoice) => { - const modelSlug = modelChoice.value.trim(); - if ( - !modelSlug || - !targetModelSlugs.has(modelSlug) || - capabilitiesBySlug.has(modelSlug) - ) { - return Effect.void.pipe( - Effect.as(undefined), - ); - } - - return withCursorAcpProbeRuntime( - cursorSettings, - (probeAcp) => - Effect.gen(function* () { - const probeStarted = yield* probeAcp.start(); - const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; - const probeModelOption = findCursorModelConfigOption(probeConfigOptions); - const probeCurrentModelValue = - probeModelOption?.type === "select" - ? probeModelOption.currentValue?.trim() || undefined - : undefined; - yield* Effect.annotateCurrentSpan({ - "cursor.acp.model.value": modelSlug, - "cursor.acp.model.currentValue": probeCurrentModelValue, - "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, - }); - const nextConfigOptions = - probeCurrentModelValue === modelSlug - ? probeConfigOptions - : yield* probeAcp - .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) - .pipe( - Effect.map((response) => response.configOptions ?? probeConfigOptions), - ); - return [ - modelSlug, - buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), - ] as const; - }), - environment, - ).pipe( - Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), - Effect.retry({ times: 3 }), - Effect.withSpan("cursor-acp-model-capability-probe"), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP capability probe failed", { - modelSlug, - cause: Cause.pretty(cause), - }), - ), - ); - }, - { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, - ); - - for (const entry of probedCapabilities) { - if (!entry) { - continue; - } - capabilitiesBySlug.set(entry[0], entry[1]); - } - - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, - })), - ); - }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), - environment, - ); +) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( cursorSettings: Pick, @@ -1219,36 +1090,30 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); }); -export function hasUncapturedCursorModels(snapshot: Pick): boolean { - return snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)); -} - /** - * Background capability enrichment for a Cursor snapshot. + * Background maintenance enrichment for a Cursor snapshot. * * Used by `CursorDriver` as the `makeManagedServerProvider.enrichSnapshot` - * hook: runs the slow ACP per-model capability probe, and republishes the - * snapshot through `publishSnapshot` when new capabilities arrive. Skips - * the probe when the provider is disabled, unauthenticated, or has no - * uncaptured models. Keeps `EMPTY_CAPABILITIES` and the `PROVIDER` literal - * private to this module. + * hook: republishes update/version advisory metadata without performing any + * model or capability discovery. Cursor model data comes exclusively from + * `cursor/list_available_models` during provider status checks. */ export const enrichCursorSnapshot = (input: { readonly settings: CursorSettings; - readonly environment?: NodeJS.ProcessEnv; readonly snapshot: ServerProvider; readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { +}): Effect.Effect => { const { settings, snapshot, publishSnapshot } = input; const stampIdentity = input.stampIdentity ?? ((value) => value); - const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory( - snapshot, - input.maintenanceCapabilities, - ).pipe( + if (!settings.enabled || snapshot.auth.status === "unauthenticated") { + return Effect.void; + } + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe( Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), @@ -1256,48 +1121,7 @@ export const enrichCursorSnapshot = (input: { Effect.catchCause((cause) => Effect.logWarning("Cursor version advisory enrichment failed", { cause: Cause.pretty(cause), - }).pipe(Effect.as(snapshot)), + }).pipe(Effect.asVoid), ), ); - - return enrichVersionAdvisory.pipe( - Effect.flatMap((baseSnapshot) => { - if ( - !settings.enabled || - baseSnapshot.auth.status === "unauthenticated" || - !hasUncapturedCursorModels(baseSnapshot) - ) { - return Effect.void; - } - - return discoverCursorModelCapabilitiesViaAcp( - settings, - baseSnapshot.models, - input.environment, - ).pipe( - Effect.flatMap((discoveredModels) => { - if (discoveredModels.length === 0) { - return Effect.void; - } - return publishSnapshot( - stampIdentity({ - ...baseSnapshot, - models: providerModelsFromSettings( - discoveredModels, - PROVIDER, - settings.customModels, - EMPTY_CAPABILITIES, - ), - }), - ); - }), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP background capability enrichment failed", { - models: baseSnapshot.models.map((model) => model.slug), - cause: Cause.pretty(cause), - }).pipe(Effect.asVoid), - ), - ); - }), - ); }; diff --git a/apps/server/src/provider/acp/CursorAcpExtension.test.ts b/apps/server/src/provider/acp/CursorAcpExtension.test.ts index 91d50c4a9b8..1c207fe927e 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.test.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + CursorListAvailableModelsResponse, extractAskQuestions, extractPlanMarkdown, extractTodosAsPlan, @@ -105,4 +106,30 @@ describe("CursorAcpExtension", () => { ], }); }); + + it("decodes Cursor list_available_models responses with per-model config options", () => { + const decoded = CursorListAvailableModelsResponse.make({ + models: [ + { + value: "gpt-5.4", + name: "GPT-5.4", + configOptions: [ + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + ], + }, + ], + }, + ], + }); + + expect(decoded.models[0]?.configOptions?.[0]?.id).toBe("reasoning"); + }); }); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts index 4ab36636fb9..2e131a61608 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -3,6 +3,7 @@ * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md */ import type { UserInputQuestion } from "@t3tools/contracts"; +import * as AcpSchema from "effect-acp/schema"; import * as Schema from "effect/Schema"; const CursorAskQuestionOption = Schema.Struct({ @@ -53,6 +54,16 @@ export const CursorUpdateTodosRequest = Schema.Struct({ merge: Schema.Boolean, }); +const CursorAvailableModel = Schema.Struct({ + value: Schema.String, + name: Schema.String, + configOptions: Schema.optional(Schema.Array(AcpSchema.SessionConfigOption)), +}); + +export const CursorListAvailableModelsResponse = Schema.Struct({ + models: Schema.Array(CursorAvailableModel), +}); + export function extractAskQuestions( params: typeof CursorAskQuestionRequest.Type, ): ReadonlyArray {