| author | |
| committer | |
| log | d78e02cd0b01faa48db4009de2a5497e95c14636 |
| tree | aba7ce0e73e66d57fc7a0eb397efe4e82107cd5e |
| parent | f0116e44b6806a949fbd449b4548f3cfc6638510 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 185 | 185 | ||
| 186 | function 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 | |||
| 198 | function 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 | |||
| 186 | const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [ | 215 | const 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 | ); |
| 518 | 547 | ||
| 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, but | 4 | * 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 without | 6 | * `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 the | 9 | * 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 to | 141 | // Model catalog and capabilities come exclusively from Cursor's |
| 143 | // live on `CursorProviderLive`. Only fires when the snapshot reports | 142 | // 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"; |
| 6 | import * as Path from "effect/Path"; | 6 | import * as Path from "effect/Path"; |
| 7 | import { describe, expect, it } from "vitest"; | 7 | import { describe, expect, it } from "vitest"; |
| 8 | import type * as EffectAcpSchema from "effect-acp/schema"; | 8 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 9 | import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; | 9 | import type { CursorSettings } from "@t3tools/contracts"; |
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | 10 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 11 | 11 | ||
| 12 | import { | 12 | import { |
| 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>; |
| 297 | 295 | ||
| 298 | const 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 | |||
| 340 | const baseCursorSettings: CursorSettings = { | 296 | const 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 | }; |
| 346 | 302 | ||
| 347 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | ||
| 348 | |||
| 349 | describe("getCursorFallbackModels", () => { | 303 | describe("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 | }); |
| 464 | 418 | ||
| 465 | describe("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 | |||
| 510 | describe("checkCursorProviderStatus", () => { | 419 | describe("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 | }); |
| 578 | 487 | ||
| 579 | describe("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 | |||
| 620 | describe("parseCursorAboutOutput", () => { | 488 | describe("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"; |
| 19 | import * as Option from "effect/Option"; | 19 | import * as Option from "effect/Option"; |
| 20 | import * as Path from "effect/Path"; | 20 | import * as Path from "effect/Path"; |
| 21 | import * as Result from "effect/Result"; | 21 | import * as Result from "effect/Result"; |
| 22 | import * as Schema from "effect/Schema"; | ||
| 22 | import { HttpClient } from "effect/unstable/http"; | 23 | import { HttpClient } from "effect/unstable/http"; |
| 23 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 24 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | import { | 25 | import { |
| ... | @@ -42,6 +43,7 @@ import { | ... | @@ -42,6 +43,7 @@ import { |
| 42 | type ProviderMaintenanceCapabilities, | 43 | type ProviderMaintenanceCapabilities, |
| 43 | } from "../providerMaintenance.ts"; | 44 | } from "../providerMaintenance.ts"; |
| 44 | import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; | 45 | import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; |
| 46 | import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; | ||
| 45 | 47 | ||
| 46 | const PROVIDER = ProviderDriverKind.make("cursor"); | 48 | const PROVIDER = ProviderDriverKind.make("cursor"); |
| 47 | const CURSOR_PRESENTATION = { | 49 | const CURSOR_PRESENTATION = { |
| ... | @@ -54,8 +56,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ... | @@ -54,8 +56,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 54 | }); | 56 | }); |
| 55 | 57 | ||
| 56 | const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; | 58 | const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; |
| 57 | const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; | ||
| 58 | const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; | ||
| 59 | const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; | 59 | const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; |
| 60 | export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { | 60 | export 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 | } |
| 156 | 156 | ||
| 157 | function findCursorModelConfigOption( | ||
| 158 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, | ||
| 159 | ): EffectAcpSchema.SessionConfigOption | undefined { | ||
| 160 | return configOptions.find((option) => option.category === "model"); | ||
| 161 | } | ||
| 162 | |||
| 163 | function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string { | 157 | function 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 | } |
| 375 | 369 | ||
| 376 | function hasCursorModelCapabilities(model: Pick<ServerProviderModel, "capabilities">): boolean { | 370 | function buildCursorDiscoveredModelsFromAvailableModelsResponse( |
| 377 | return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | 371 | response: typeof CursorListAvailableModelsResponse.Type, |
| 378 | } | ||
| 379 | |||
| 380 | export 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 | ? currentModelCapabilities | 379 | } |
| 404 | : EMPTY_CAPABILITIES, | 380 | |
| 405 | })), | 381 | return [ |
| 382 | { | ||
| 383 | slug, | ||
| 384 | name, | ||
| 385 | capabilities: buildCursorCapabilitiesFromConfigOptions(model.configOptions), | ||
| 386 | }, | ||
| 387 | ]; | ||
| 388 | }), | ||
| 406 | ); | 389 | ); |
| 407 | } | 390 | } |
| 408 | 391 | ||
| ... | @@ -554,140 +537,28 @@ export function resolveCursorAcpConfigUpdates( | ... | @@ -554,140 +537,28 @@ export function resolveCursorAcpConfigUpdates( |
| 554 | return updates; | 537 | return updates; |
| 555 | } | 538 | } |
| 556 | 539 | ||
| 557 | export const discoverCursorModelsViaAcp = ( | 540 | const 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 | ); |
| 571 | 557 | ||
| 572 | export const discoverCursorModelCapabilitiesViaAcp = ( | 558 | export 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 | ); | ||
| 691 | 562 | ||
| 692 | export function getCursorFallbackModels( | 563 | export 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 | }); |
| 1221 | 1092 | ||
| 1222 | export 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 the | 1097 | * hook: republishes update/version advisory metadata without performing any |
| 1231 | * snapshot through `publishSnapshot` when new capabilities arrive. Skips | 1098 | * model or capability discovery. Cursor model data comes exclusively from |
| 1232 | * the probe when the provider is disabled, unauthenticated, or has no | 1099 | * `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 | */ |
| 1236 | export const enrichCursorSnapshot = (input: { | 1101 | export 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); |
| 1247 | 1111 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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.md | 3 | * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md |
| 4 | */ | 4 | */ |
| 5 | import type { UserInputQuestion } from "@t3tools/contracts"; | 5 | import type { UserInputQuestion } from "@t3tools/contracts"; |
| 6 | import * as AcpSchema from "effect-acp/schema"; | ||
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | ||
| 8 | const CursorAskQuestionOption = Schema.Struct({ | 9 | const 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 | }); |
| 55 | 56 | ||
| 57 | const CursorAvailableModel = Schema.Struct({ | ||
| 58 | value: Schema.String, | ||
| 59 | name: Schema.String, | ||
| 60 | configOptions: Schema.optional(Schema.Array(AcpSchema.SessionConfigOption)), | ||
| 61 | }); | ||
| 62 | |||
| 63 | export const CursorListAvailableModelsResponse = Schema.Struct({ | ||
| 64 | models: Schema.Array(CursorAvailableModel), | ||
| 65 | }); | ||
| 66 | |||
| 56 | export function extractAskQuestions( | 67 | export function extractAskQuestions( |
| 57 | params: typeof CursorAskQuestionRequest.Type, | 68 | params: typeof CursorAskQuestionRequest.Type, |
| 58 | ): ReadonlyArray<UserInputQuestion> { | 69 | ): ReadonlyArray<UserInputQuestion> { |