authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-03 10:06:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-03 17:06:52+00:00
logd78e02cd0b01faa48db4009de2a5497e95c14636
treeaba7ce0e73e66d57fc7a0eb397efe4e82107cd5e
parentf0116e44b6806a949fbd449b4548f3cfc6638510
signature Signed by PGP key B5690EEEBB952194

Probe Cursor models via list_available_models (#2428)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius@mac.lan>

6 files changed, 121 insertions(+), 360 deletions(-)

apps/server/scripts/acp-mock-agent.ts+35
...@@ -183,6 +183,35 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {...@@ -183,6 +183,35 @@ function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
183 ];183 ];
184}184}
185185
186function modelConfigOptionsFor(modelId: string): ReadonlyArray<AcpSchema.SessionConfigOption> {
187 const previousModelId = currentModelId;
188 try {
189 currentModelId = modelId;
190 return configOptions().filter(
191 (option) => option.category !== "mode" && option.category !== "model",
192 );
193 } finally {
194 currentModelId = previousModelId;
195 }
196}
197
198function availableModels(): ReadonlyArray<{
199 readonly value: string;
200 readonly name: string;
201 readonly configOptions: ReadonlyArray<AcpSchema.SessionConfigOption>;
202}> {
203 return [
204 { value: "default", name: "Auto" },
205 { value: "composer-2", name: "Composer 2" },
206 { value: "gpt-5.4", name: "GPT-5.4" },
207 { value: "claude-opus-4-6", name: "Opus 4.6" },
208 ].map((model) => ({
209 value: model.value,
210 name: model.name,
211 configOptions: modelConfigOptionsFor(model.value),
212 }));
213}
214
186const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [215const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [
187 {216 {
188 id: "ask",217 id: "ask",
...@@ -517,6 +546,12 @@ const program = Effect.gen(function* () {...@@ -517,6 +546,12 @@ const program = Effect.gen(function* () {
517 );546 );
518547
519 yield* agent.handleUnknownExtRequest((method, params) => {548 yield* agent.handleUnknownExtRequest((method, params) => {
549 if (method === "cursor/list_available_models") {
550 return Effect.succeed({
551 models: availableModels(),
552 });
553 }
554
520 if (method !== "session/mode/set") {555 if (method !== "session/mode/set") {
521 return Effect.fail(AcpError.AcpRequestError.methodNotFound(method));556 return Effect.fail(AcpError.AcpRequestError.methodNotFound(method));
522 }557 }
apps/server/src/provider/Drivers/CursorDriver.ts+6-10
...@@ -1,10 +1,9 @@...@@ -1,10 +1,9 @@
1/**1/**
2 * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime.2 * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime.
3 *3 *
4 * Cursor exposes an ACP-based CLI. The driver is still a plain value, but4 * Cursor exposes an ACP-based CLI. Model catalog and capability refreshes
5 * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot`5 * happen during the managed provider status check via Cursor's
6 * hook to run the slow ACP model-capability probe in the background without6 * `list_available_models` extension method.
7 * blocking the initial `ready`-state publish.
8 *7 *
9 * Text generation is supported via the ACP runtime — `makeCursorTextGeneration`8 * Text generation is supported via the ACP runtime — `makeCursorTextGeneration`
10 * drives `runtime.prompt` with a structured-output schema and collects the9 * drives `runtime.prompt` with a structured-output schema and collects the
...@@ -139,20 +138,17 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -139,20 +138,17 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
139 initialSnapshot: (settings) =>138 initialSnapshot: (settings) =>
140 buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)),139 buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)),
141 checkProvider,140 checkProvider,
142 // Preserve the background ACP model-capability probe that used to141 // Model catalog and capabilities come exclusively from Cursor's
143 // live on `CursorProviderLive`. Only fires when the snapshot reports142 // list_available_models extension method during provider checks.
144 // an authenticated, enabled provider with at least one non-custom
145 // model whose capabilities haven't been captured yet.
146 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>143 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
147 enrichCursorSnapshot({144 enrichCursorSnapshot({
148 settings,145 settings,
149 environment: processEnv,
150 snapshot: currentSnapshot,146 snapshot: currentSnapshot,
151 maintenanceCapabilities,147 maintenanceCapabilities,
152 publishSnapshot,148 publishSnapshot,
153 stampIdentity,149 stampIdentity,
154 httpClient,150 httpClient,
155 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),151 }),
156 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,152 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
157 }).pipe(153 }).pipe(
158 Effect.mapError(154 Effect.mapError(
apps/server/src/provider/Layers/CursorProvider.test.ts+1-133
...@@ -6,15 +6,13 @@ import * as FileSystem from "effect/FileSystem";...@@ -6,15 +6,13 @@ import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
7import { describe, expect, it } from "vitest";7import { describe, expect, it } from "vitest";
8import type * as EffectAcpSchema from "effect-acp/schema";8import type * as EffectAcpSchema from "effect-acp/schema";
9import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";9import type { CursorSettings } from "@t3tools/contracts";
10import { createModelCapabilities } from "@t3tools/shared/model";10import { createModelCapabilities } from "@t3tools/shared/model";
1111
12import {12import {
13 buildCursorProviderSnapshot,13 buildCursorProviderSnapshot,
14 buildCursorCapabilitiesFromConfigOptions,14 buildCursorCapabilitiesFromConfigOptions,
15 buildCursorDiscoveredModelsFromConfigOptions,
16 checkCursorProviderStatus,15 checkCursorProviderStatus,
17 discoverCursorModelCapabilitiesViaAcp,
18 discoverCursorModelsViaAcp,16 discoverCursorModelsViaAcp,
19 getCursorFallbackModels,17 getCursorFallbackModels,
20 getCursorParameterizedModelPickerUnsupportedMessage,18 getCursorParameterizedModelPickerUnsupportedMessage,
...@@ -295,48 +293,6 @@ const parameterizedClaudeModelOptionConfigOptions = [...@@ -295,48 +293,6 @@ const parameterizedClaudeModelOptionConfigOptions = [
295 },293 },
296] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;294] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
297295
298const sessionNewCursorConfigOptions = [
299 {
300 type: "select",
301 currentValue: "agent",
302 options: [
303 { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" },
304 ],
305 category: "mode",
306 id: "mode",
307 name: "Mode",
308 description: "Controls how the agent executes tasks",
309 },
310 {
311 type: "select",
312 currentValue: "composer-2",
313 options: [
314 { name: "Auto", value: "default" },
315 { name: "Composer 2", value: "composer-2" },
316 { name: "GPT-5.4", value: "gpt-5.4" },
317 { name: "Sonnet 4.6", value: "claude-sonnet-4-6" },
318 { name: "Opus 4.6", value: "claude-opus-4-6" },
319 { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" },
320 ],
321 category: "model",
322 id: "model",
323 name: "Model",
324 description: "Controls which model is used for responses",
325 },
326 {
327 type: "select",
328 currentValue: "true",
329 options: [
330 { name: "Off", value: "false" },
331 { name: "Fast", value: "true" },
332 ],
333 category: "model_config",
334 id: "fast",
335 name: "Fast",
336 description: "Faster speeds.",
337 },
338] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
339
340const baseCursorSettings: CursorSettings = {296const baseCursorSettings: CursorSettings = {
341 enabled: true,297 enabled: true,
342 binaryPath: "agent",298 binaryPath: "agent",
...@@ -344,8 +300,6 @@ const baseCursorSettings: CursorSettings = {...@@ -344,8 +300,6 @@ const baseCursorSettings: CursorSettings = {
344 customModels: [],300 customModels: [],
345};301};
346302
347const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
348
349describe("getCursorFallbackModels", () => {303describe("getCursorFallbackModels", () => {
350 it("does not publish any built-in cursor models before ACP discovery", () => {304 it("does not publish any built-in cursor models before ACP discovery", () => {
351 expect(305 expect(
...@@ -462,51 +416,6 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => {...@@ -462,51 +416,6 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => {
462 });416 });
463});417});
464418
465describe("buildCursorDiscoveredModelsFromConfigOptions", () => {
466 it("publishes ACP model choices immediately from session/new config options", () => {
467 expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([
468 {
469 slug: "default",
470 name: "Auto",
471 isCustom: false,
472 capabilities: emptyCapabilities,
473 },
474 {
475 slug: "composer-2",
476 name: "Composer 2",
477 isCustom: false,
478 capabilities: createModelCapabilities({
479 optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)],
480 }),
481 },
482 {
483 slug: "gpt-5.4",
484 name: "GPT-5.4",
485 isCustom: false,
486 capabilities: emptyCapabilities,
487 },
488 {
489 slug: "claude-sonnet-4-6",
490 name: "Sonnet 4.6",
491 isCustom: false,
492 capabilities: emptyCapabilities,
493 },
494 {
495 slug: "claude-opus-4-6",
496 name: "Opus 4.6",
497 isCustom: false,
498 capabilities: emptyCapabilities,
499 },
500 {
501 slug: "gpt-5.3-codex-spark",
502 name: "Codex 5.3 Spark",
503 isCustom: false,
504 capabilities: emptyCapabilities,
505 },
506 ]);
507 });
508});
509
510describe("checkCursorProviderStatus", () => {419describe("checkCursorProviderStatus", () => {
511 it("passes the injected environment to ACP model discovery", async () => {420 it("passes the injected environment to ACP model discovery", async () => {
512 const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture());421 const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture());
...@@ -576,47 +485,6 @@ describe("discoverCursorModelsViaAcp", () => {...@@ -576,47 +485,6 @@ describe("discoverCursorModelsViaAcp", () => {
576 });485 });
577});486});
578487
579describe("discoverCursorModelCapabilitiesViaAcp", () => {
580 it("closes all ACP probe runtimes after capability enrichment completes", async () => {
581 const { exitLogPath, wrapperPath } = await runNode(
582 makeExitLogFixture("cursor-capabilities-exit-log-"),
583 );
584 const existingModels: ReadonlyArray<ServerProviderModel> = [
585 { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities },
586 { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities },
587 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities },
588 {
589 slug: "claude-opus-4-6",
590 name: "Opus 4.6",
591 isCustom: false,
592 capabilities: emptyCapabilities,
593 },
594 ];
595
596 const models = await Effect.runPromise(
597 discoverCursorModelCapabilitiesViaAcp(
598 {
599 enabled: true,
600 binaryPath: wrapperPath,
601 apiEndpoint: "",
602 customModels: [],
603 },
604 existingModels,
605 ).pipe(Effect.provide(NodeServices.layer)),
606 );
607
608 expect(models.map((model) => model.slug)).toEqual([
609 "default",
610 "composer-2",
611 "gpt-5.4",
612 "claude-opus-4-6",
613 ]);
614
615 const exitLog = await runNode(waitForFileContent(exitLogPath));
616 expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4);
617 });
618});
619
620describe("parseCursorAboutOutput", () => {488describe("parseCursorAboutOutput", () => {
621 it("parses json about output and forwards subscription metadata", () => {489 it("parses json about output and forwards subscription metadata", () => {
622 expect(490 expect(
apps/server/src/provider/Layers/CursorProvider.ts+41-217
...@@ -19,6 +19,7 @@ import * as Layer from "effect/Layer";...@@ -19,6 +19,7 @@ import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";19import * as Option from "effect/Option";
20import * as Path from "effect/Path";20import * as Path from "effect/Path";
21import * as Result from "effect/Result";21import * as Result from "effect/Result";
22import * as Schema from "effect/Schema";
22import { HttpClient } from "effect/unstable/http";23import { HttpClient } from "effect/unstable/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";24import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
24import {25import {
...@@ -42,6 +43,7 @@ import {...@@ -42,6 +43,7 @@ import {
42 type ProviderMaintenanceCapabilities,43 type ProviderMaintenanceCapabilities,
43} from "../providerMaintenance.ts";44} from "../providerMaintenance.ts";
44import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";45import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
46import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts";
4547
46const PROVIDER = ProviderDriverKind.make("cursor");48const PROVIDER = ProviderDriverKind.make("cursor");
47const CURSOR_PRESENTATION = {49const CURSOR_PRESENTATION = {
...@@ -54,8 +56,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({...@@ -54,8 +56,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
54});56});
5557
56const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;58const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
57const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";
58const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4;
59const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;59const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;
60export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {60export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {
61 _meta: {61 _meta: {
...@@ -154,12 +154,6 @@ function normalizeCursorReasoningValue(value: string | null | undefined): string...@@ -154,12 +154,6 @@ function normalizeCursorReasoningValue(value: string | null | undefined): string
154 }154 }
155}155}
156156
157function findCursorModelConfigOption(
158 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
159): EffectAcpSchema.SessionConfigOption | undefined {
160 return configOptions.find((option) => option.category === "model");
161}
162
163function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string {157function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string {
164 return option.category?.trim().toLowerCase() ?? "";158 return option.category?.trim().toLowerCase() ?? "";
165}159}
...@@ -373,36 +367,25 @@ function buildCursorDiscoveredModels(...@@ -373,36 +367,25 @@ function buildCursorDiscoveredModels(
373 });367 });
374}368}
375369
376function hasCursorModelCapabilities(model: Pick<ServerProviderModel, "capabilities">): boolean {370function buildCursorDiscoveredModelsFromAvailableModelsResponse(
377 return (model.capabilities?.optionDescriptors?.length ?? 0) > 0;371 response: typeof CursorListAvailableModelsResponse.Type,
378}
379
380export function buildCursorDiscoveredModelsFromConfigOptions(
381 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
382): ReadonlyArray<ServerProviderModel> {372): ReadonlyArray<ServerProviderModel> {
383 if (!configOptions || configOptions.length === 0) {
384 return [];
385 }
386
387 const modelOption = findCursorModelConfigOption(configOptions);
388 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
389 if (!modelOption || modelChoices.length === 0) {
390 return [];
391 }
392
393 const currentModelValue =
394 modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined;
395 const currentModelCapabilities = buildCursorCapabilitiesFromConfigOptions(configOptions);
396
397 return buildCursorDiscoveredModels(373 return buildCursorDiscoveredModels(
398 modelChoices.map((modelChoice) => ({374 response.models.flatMap((model) => {
399 slug: modelChoice.value.trim(),375 const slug = model.value.trim();
400 name: modelChoice.name.trim(),376 const name = model.name.trim();
401 capabilities:377 if (!slug || !name) {
402 currentModelValue === modelChoice.value.trim()378 return [];
403 ? currentModelCapabilities379 }
404 : EMPTY_CAPABILITIES,380
405 })),381 return [
382 {
383 slug,
384 name,
385 capabilities: buildCursorCapabilitiesFromConfigOptions(model.configOptions),
386 },
387 ];
388 }),
406 );389 );
407}390}
408391
...@@ -554,140 +537,28 @@ export function resolveCursorAcpConfigUpdates(...@@ -554,140 +537,28 @@ export function resolveCursorAcpConfigUpdates(
554 return updates;537 return updates;
555}538}
556539
557export const discoverCursorModelsViaAcp = (540const discoverCursorModelsViaListAvailableModels = (
558 cursorSettings: CursorSettings,541 cursorSettings: CursorSettings,
559 environment: NodeJS.ProcessEnv = process.env,542 environment: NodeJS.ProcessEnv = process.env,
560) =>543) =>
561 withCursorAcpProbeRuntime(544 withCursorAcpProbeRuntime(
562 cursorSettings,545 cursorSettings,
563 (acp) =>546 (acp) =>
564 Effect.map(acp.start(), (started) =>547 Effect.gen(function* () {
565 buildCursorDiscoveredModelsFromConfigOptions(548 yield* acp.start();
566 started.sessionSetupResult.configOptions ?? [],549 const response = yield* acp.request("cursor/list_available_models", {});
567 ),550 const decoded = yield* Schema.decodeUnknownEffect(CursorListAvailableModelsResponse)(
568 ),551 response,
552 );
553 return buildCursorDiscoveredModelsFromAvailableModelsResponse(decoded);
554 }),
569 environment,555 environment,
570 );556 );
571557
572export const discoverCursorModelCapabilitiesViaAcp = (558export const discoverCursorModelsViaAcp = (
573 cursorSettings: CursorSettings,559 cursorSettings: CursorSettings,
574 existingModels: ReadonlyArray<ServerProviderModel>,
575 environment: NodeJS.ProcessEnv = process.env,560 environment: NodeJS.ProcessEnv = process.env,
576) =>561) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment);
577 withCursorAcpProbeRuntime(
578 cursorSettings,
579 (acp) =>
580 Effect.gen(function* () {
581 const started = yield* acp.start();
582 const initialConfigOptions = started.sessionSetupResult.configOptions ?? [];
583 const modelOption = findCursorModelConfigOption(initialConfigOptions);
584 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
585 if (!modelOption || modelChoices.length === 0) {
586 return [];
587 }
588
589 const currentModelValue =
590 modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined;
591 const capabilitiesBySlug = new Map<string, ModelCapabilities>();
592 if (currentModelValue) {
593 capabilitiesBySlug.set(
594 currentModelValue,
595 buildCursorCapabilitiesFromConfigOptions(initialConfigOptions),
596 );
597 }
598
599 const targetModelSlugs = new Set<string>();
600 for (const model of existingModels) {
601 if (!model.isCustom && !hasCursorModelCapabilities(model)) {
602 targetModelSlugs.add(model.slug);
603 }
604 }
605 if (targetModelSlugs.size === 0) {
606 return buildCursorDiscoveredModels(
607 modelChoices.map((modelChoice) => ({
608 slug: modelChoice.value.trim(),
609 name: modelChoice.name.trim(),
610 capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES,
611 })),
612 );
613 }
614
615 const probedCapabilities = yield* Effect.forEach(
616 modelChoices,
617 (modelChoice) => {
618 const modelSlug = modelChoice.value.trim();
619 if (
620 !modelSlug ||
621 !targetModelSlugs.has(modelSlug) ||
622 capabilitiesBySlug.has(modelSlug)
623 ) {
624 return Effect.void.pipe(
625 Effect.as<readonly [string, ModelCapabilities] | undefined>(undefined),
626 );
627 }
628
629 return withCursorAcpProbeRuntime(
630 cursorSettings,
631 (probeAcp) =>
632 Effect.gen(function* () {
633 const probeStarted = yield* probeAcp.start();
634 const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? [];
635 const probeModelOption = findCursorModelConfigOption(probeConfigOptions);
636 const probeCurrentModelValue =
637 probeModelOption?.type === "select"
638 ? probeModelOption.currentValue?.trim() || undefined
639 : undefined;
640 yield* Effect.annotateCurrentSpan({
641 "cursor.acp.model.value": modelSlug,
642 "cursor.acp.model.currentValue": probeCurrentModelValue,
643 "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id,
644 });
645 const nextConfigOptions =
646 probeCurrentModelValue === modelSlug
647 ? probeConfigOptions
648 : yield* probeAcp
649 .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug)
650 .pipe(
651 Effect.map((response) => response.configOptions ?? probeConfigOptions),
652 );
653 return [
654 modelSlug,
655 buildCursorCapabilitiesFromConfigOptions(nextConfigOptions),
656 ] as const;
657 }),
658 environment,
659 ).pipe(
660 Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT),
661 Effect.retry({ times: 3 }),
662 Effect.withSpan("cursor-acp-model-capability-probe"),
663 Effect.catchCause((cause) =>
664 Effect.logWarning("Cursor ACP capability probe failed", {
665 modelSlug,
666 cause: Cause.pretty(cause),
667 }),
668 ),
669 );
670 },
671 { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY },
672 );
673
674 for (const entry of probedCapabilities) {
675 if (!entry) {
676 continue;
677 }
678 capabilitiesBySlug.set(entry[0], entry[1]);
679 }
680
681 return buildCursorDiscoveredModels(
682 modelChoices.map((modelChoice) => ({
683 slug: modelChoice.value.trim(),
684 name: modelChoice.name.trim(),
685 capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES,
686 })),
687 );
688 }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})),
689 environment,
690 );
691562
692export function getCursorFallbackModels(563export function getCursorFallbackModels(
693 cursorSettings: Pick<CursorSettings, "customModels">,564 cursorSettings: Pick<CursorSettings, "customModels">,
...@@ -1219,36 +1090,30 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(...@@ -1219,36 +1090,30 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
1219 });1090 });
1220});1091});
12211092
1222export function hasUncapturedCursorModels(snapshot: Pick<ServerProvider, "models">): boolean {
1223 return snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model));
1224}
1225
1226/**1093/**
1227 * Background capability enrichment for a Cursor snapshot.1094 * Background maintenance enrichment for a Cursor snapshot.
1228 *1095 *
1229 * Used by `CursorDriver` as the `makeManagedServerProvider.enrichSnapshot`1096 * Used by `CursorDriver` as the `makeManagedServerProvider.enrichSnapshot`
1230 * hook: runs the slow ACP per-model capability probe, and republishes the1097 * hook: republishes update/version advisory metadata without performing any
1231 * snapshot through `publishSnapshot` when new capabilities arrive. Skips1098 * model or capability discovery. Cursor model data comes exclusively from
1232 * the probe when the provider is disabled, unauthenticated, or has no1099 * `cursor/list_available_models` during provider status checks.
1233 * uncaptured models. Keeps `EMPTY_CAPABILITIES` and the `PROVIDER` literal
1234 * private to this module.
1235 */1100 */
1236export const enrichCursorSnapshot = (input: {1101export const enrichCursorSnapshot = (input: {
1237 readonly settings: CursorSettings;1102 readonly settings: CursorSettings;
1238 readonly environment?: NodeJS.ProcessEnv;
1239 readonly snapshot: ServerProvider;1103 readonly snapshot: ServerProvider;
1240 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;1104 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
1241 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;1105 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
1242 readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider;1106 readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider;
1243 readonly httpClient: HttpClient.HttpClient;1107 readonly httpClient: HttpClient.HttpClient;
1244}): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {1108}): Effect.Effect<void> => {
1245 const { settings, snapshot, publishSnapshot } = input;1109 const { settings, snapshot, publishSnapshot } = input;
1246 const stampIdentity = input.stampIdentity ?? ((value) => value);1110 const stampIdentity = input.stampIdentity ?? ((value) => value);
12471111
1248 const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory(1112 if (!settings.enabled || snapshot.auth.status === "unauthenticated") {
1249 snapshot,1113 return Effect.void;
1250 input.maintenanceCapabilities,1114 }
1251 ).pipe(1115
1116 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe(
1252 Effect.provideService(HttpClient.HttpClient, input.httpClient),1117 Effect.provideService(HttpClient.HttpClient, input.httpClient),
1253 Effect.flatMap((enrichedSnapshot) =>1118 Effect.flatMap((enrichedSnapshot) =>
1254 publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),1119 publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),
...@@ -1256,48 +1121,7 @@ export const enrichCursorSnapshot = (input: {...@@ -1256,48 +1121,7 @@ export const enrichCursorSnapshot = (input: {
1256 Effect.catchCause((cause) =>1121 Effect.catchCause((cause) =>
1257 Effect.logWarning("Cursor version advisory enrichment failed", {1122 Effect.logWarning("Cursor version advisory enrichment failed", {
1258 cause: Cause.pretty(cause),1123 cause: Cause.pretty(cause),
1259 }).pipe(Effect.as(snapshot)),1124 }).pipe(Effect.asVoid),
1260 ),1125 ),
1261 );1126 );
1262
1263 return enrichVersionAdvisory.pipe(
1264 Effect.flatMap((baseSnapshot) => {
1265 if (
1266 !settings.enabled ||
1267 baseSnapshot.auth.status === "unauthenticated" ||
1268 !hasUncapturedCursorModels(baseSnapshot)
1269 ) {
1270 return Effect.void;
1271 }
1272
1273 return discoverCursorModelCapabilitiesViaAcp(
1274 settings,
1275 baseSnapshot.models,
1276 input.environment,
1277 ).pipe(
1278 Effect.flatMap((discoveredModels) => {
1279 if (discoveredModels.length === 0) {
1280 return Effect.void;
1281 }
1282 return publishSnapshot(
1283 stampIdentity({
1284 ...baseSnapshot,
1285 models: providerModelsFromSettings(
1286 discoveredModels,
1287 PROVIDER,
1288 settings.customModels,
1289 EMPTY_CAPABILITIES,
1290 ),
1291 }),
1292 );
1293 }),
1294 Effect.catchCause((cause) =>
1295 Effect.logWarning("Cursor ACP background capability enrichment failed", {
1296 models: baseSnapshot.models.map((model) => model.slug),
1297 cause: Cause.pretty(cause),
1298 }).pipe(Effect.asVoid),
1299 ),
1300 );
1301 }),
1302 );
1303};1127};
apps/server/src/provider/acp/CursorAcpExtension.test.ts+27
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 CursorListAvailableModelsResponse,
4 extractAskQuestions,5 extractAskQuestions,
5 extractPlanMarkdown,6 extractPlanMarkdown,
6 extractTodosAsPlan,7 extractTodosAsPlan,
...@@ -105,4 +106,30 @@ describe("CursorAcpExtension", () => {...@@ -105,4 +106,30 @@ describe("CursorAcpExtension", () => {
105 ],106 ],
106 });107 });
107 });108 });
109
110 it("decodes Cursor list_available_models responses with per-model config options", () => {
111 const decoded = CursorListAvailableModelsResponse.make({
112 models: [
113 {
114 value: "gpt-5.4",
115 name: "GPT-5.4",
116 configOptions: [
117 {
118 id: "reasoning",
119 name: "Reasoning",
120 category: "thought_level",
121 type: "select",
122 currentValue: "medium",
123 options: [
124 { value: "low", name: "Low" },
125 { value: "medium", name: "Medium" },
126 ],
127 },
128 ],
129 },
130 ],
131 });
132
133 expect(decoded.models[0]?.configOptions?.[0]?.id).toBe("reasoning");
134 });
108});135});
apps/server/src/provider/acp/CursorAcpExtension.ts+11
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3 * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md3 * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md
4 */4 */
5import type { UserInputQuestion } from "@t3tools/contracts";5import type { UserInputQuestion } from "@t3tools/contracts";
6import * as AcpSchema from "effect-acp/schema";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
78
8const CursorAskQuestionOption = Schema.Struct({9const CursorAskQuestionOption = Schema.Struct({
...@@ -53,6 +54,16 @@ export const CursorUpdateTodosRequest = Schema.Struct({...@@ -53,6 +54,16 @@ export const CursorUpdateTodosRequest = Schema.Struct({
53 merge: Schema.Boolean,54 merge: Schema.Boolean,
54});55});
5556
57const CursorAvailableModel = Schema.Struct({
58 value: Schema.String,
59 name: Schema.String,
60 configOptions: Schema.optional(Schema.Array(AcpSchema.SessionConfigOption)),
61});
62
63export const CursorListAvailableModelsResponse = Schema.Struct({
64 models: Schema.Array(CursorAvailableModel),
65});
66
56export function extractAskQuestions(67export function extractAskQuestions(
57 params: typeof CursorAskQuestionRequest.Type,68 params: typeof CursorAskQuestionRequest.Type,
58): ReadonlyArray<UserInputQuestion> {69): ReadonlyArray<UserInputQuestion> {