diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 45e82b314..6dad9e8bf 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1195,16 +1195,28 @@ function parseConflictLaneArgs(value: Record, action: string): }; } -function parseChatModelsArgs(value: Record): { provider: AgentChatProvider; activateRuntime?: boolean } { +function parseCursorModelSource(value: unknown): "sdk" | "cli" | "all" | null { + const source = asTrimmedString(value); + return source === "sdk" || source === "cli" || source === "all" ? source : null; +} + +function parseChatModelsArgs(value: Record): { + provider: AgentChatProvider; + activateRuntime?: boolean; + cursorSource?: "sdk" | "cli" | "all"; +} { + const cursorSource = parseCursorModelSource(value.cursorSource); return { provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider, ...(value.activateRuntime === true ? { activateRuntime: true } : {}), + ...(cursorSource ? { cursorSource } : {}), }; } function parseChatModelCatalogArgs(value: Record): AgentChatModelCatalogArgs { const mode = asTrimmedString(value.mode) as AgentChatModelCatalogMode | null; const refreshProvider = asTrimmedString(value.refreshProvider) as AgentChatModelCatalogRefreshProvider | null; + const cursorSource = parseCursorModelSource(value.cursorSource); return { ...(mode === "cached" || mode === "refresh-stale" || mode === "force" ? { mode } : {}), ...( @@ -1216,6 +1228,7 @@ function parseChatModelCatalogArgs(value: Record): AgentChatMod ? { refreshProvider } : {} ), + ...(cursorSource ? { cursorSource } : {}), }; } diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index e7a636a8c..20836dfd7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -312,7 +312,9 @@ describe("getAvailableModels", () => { { domain: "chat", action: "getAvailableModels", - args: { provider: "cursor", activateRuntime: true }, + // TUI chats run cursor through the SDK, so only that source is probed + // synchronously; the CLI flavor revalidates in the background on the host. + args: { provider: "cursor", activateRuntime: true, cursorSource: "sdk" }, }, ]); }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 42592a11e..be758891c 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -347,6 +347,9 @@ export async function getAvailableModels( // Codex is intentionally NOT here: its tiers come from the app-server, which // loadAvailableModels always queries regardless of activateRuntime. activateRuntime: provider === "cursor" || provider === "droid", + // TUI chats run cursor models through the SDK; probing only that source + // keeps the picker refresh off the slower cursor-agent CLI spawn. + ...(provider === "cursor" ? { cursorSource: "sdk" } : {}), }); } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 76a596034..331636992 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -4970,6 +4970,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const catalog = await getModelCatalog(conn, { mode: options.refreshProvider ? "refresh-stale" : "cached", ...(options.refreshProvider ? { refreshProvider: options.refreshProvider } : {}), + // TUI chats run cursor models through the SDK; refreshing only that + // source keeps the catalog probe off the slower host cursor-agent CLI + // spawn (mirrors getAvailableModels and the desktop ModelPicker). + ...(options.refreshProvider === "cursor" ? { cursorSource: "sdk" as const } : {}), }); modelCatalogRef.current = catalog; setModelCatalog(catalog); @@ -4980,6 +4984,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, void getModelCatalog(conn, { mode: "force", refreshProvider: options.refreshProvider, + ...(options.refreshProvider === "cursor" ? { cursorSource: "sdk" as const } : {}), }).then((freshCatalog) => { if (connectionRef.current !== conn) return; modelCatalogRef.current = freshCatalog; diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index fe780cf69..40e62ca56 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -8,6 +8,7 @@ const mockState = vi.hoisted(() => ({ buildProviderConnections: vi.fn(), inspectLocalProvider: vi.fn(), clearCursorCliModelsCache: vi.fn(), + markCursorModelCachesStale: vi.fn(), discoverCursorCliModelDescriptors: vi.fn(), discoverCursorSdkModelDescriptors: vi.fn(), probeCursorSdkModelDiscovery: vi.fn(), @@ -40,6 +41,7 @@ vi.mock("./localModelDiscovery", () => ({ vi.mock("../chat/cursorModelsDiscovery", () => ({ clearCursorCliModelsCache: (...args: unknown[]) => mockState.clearCursorCliModelsCache(...args), + markCursorModelCachesStale: (...args: unknown[]) => mockState.markCursorModelCachesStale(...args), discoverCursorCliModelDescriptors: (...args: unknown[]) => mockState.discoverCursorCliModelDescriptors(...args), discoverCursorSdkModelDescriptors: (...args: unknown[]) => mockState.discoverCursorSdkModelDescriptors(...args), probeCursorSdkModelDiscovery: (...args: unknown[]) => mockState.probeCursorSdkModelDiscovery(...args), @@ -576,7 +578,10 @@ describe("aiIntegrationService", () => { }); expect(refreshedStatus.availableProviders.cursor).toBe(true); expect(refreshedStatus.availableModelIds).toContain("cursor/auto"); - expect(mockState.clearCursorCliModelsCache).toHaveBeenCalled(); + // Verification ages the dynamic model caches without dropping + // last-known-good rows; a full clear only happens on a key change. + expect(mockState.markCursorModelCachesStale).toHaveBeenCalled(); + expect(mockState.clearCursorCliModelsCache).not.toHaveBeenCalled(); expect(mockState.clearOpenCodeInventoryCache).toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 2351331e0..2a7ef951e 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -67,9 +67,10 @@ import { inspectLocalProvider } from "./localModelDiscovery"; import { discoverCursorSdkModelDescriptors, clearCursorCliModelsCache, + markCursorModelCachesStale, probeCursorSdkModelDiscovery, } from "../chat/cursorModelsDiscovery"; -import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../chat/droidModelsDiscovery"; +import { discoverDroidCliModelDescriptors, markDroidModelCachesStale } from "../chat/droidModelsDiscovery"; import { resolveDroidExecutable } from "./droidExecutable"; import { buildProviderConnections } from "./providerConnectionStatus"; import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth"; @@ -999,7 +1000,10 @@ export function createAiIntegrationService(args: { let available = getAvailableModels(auth); const discoveryMode = options?.discoverCliModels === true ? "probe" : "cached-or-fallback"; - const cursorDiscoveryMode = options?.discoverCliModels === true ? "probe" : "cached-only"; + // "cached-or-fallback" serves last-known-good rows and warms the cache in + // the background when cold, so a verified key surfaces models on passive + // status reads (availableModelIds, mobile, TUI) without an active probe. + const cursorDiscoveryMode = options?.discoverCliModels === true ? "probe" : "cached-or-fallback"; const cursorApiKey = getCursorApiKeyFromAuth(auth); if (cursorApiKey) { @@ -1626,8 +1630,14 @@ export function createAiIntegrationService(args: { resetProviderRuntimeHealth(); resetClaudeRuntimeProbeCache(); resetLocalProviderDetectionCache(); - clearCursorCliModelsCache(); - clearDroidCliModelsCache(); + // Keep last-known-good cursor/droid model rows: generic readiness + // invalidation runs on every forced status refresh and on verifying ANY + // provider's key, and blanking the dynamic model lists here made cursor + // models vanish from every surface right after a successful verification. + // A cursor key change does a full clear at the storeApiKey/deleteApiKey + // call sites; droid auth is file-based, so it has no such call site. + markCursorModelCachesStale(); + markDroidModelCachesStale(); clearOpenCodeBinaryCache(); clearOpenCodeInventoryCache(); replaceDynamicOpenCodeModelDescriptors([]); @@ -1902,10 +1912,12 @@ export function createAiIntegrationService(args: { verifyApiKeyConnection, storeApiKey(provider: string, key: string): void { storeStoredApiKey(provider, key); + if (provider.trim().toLowerCase() === "cursor") clearCursorCliModelsCache(); invalidateProviderReadinessCaches(); }, deleteApiKey(provider: string): void { deleteStoredApiKey(provider); + if (provider.trim().toLowerCase() === "cursor") clearCursorCliModelsCache(); invalidateProviderReadinessCaches(); }, listApiKeys(): string[] { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 4dd2d0153..bf1162cae 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -11084,6 +11084,17 @@ describe("createAgentChatService", () => { cursorAvailability: { cli: true, sdk: false }, }); expect(models[0]?.description).toContain("Cursor CLI"); + + // A surface that runs Cursor through the SDK (cursorSource: "sdk", e.g. + // TUI/mobile chat) must not be offered these CLI-only models — they'd + // fail on selection. With no SDK key configured, the sdk-scoped request + // returns nothing rather than leaking the CLI-only rows. + const sdkScoped = await service.getAvailableModels({ + provider: "cursor", + activateRuntime: true, + cursorSource: "sdk", + }); + expect(sdkScoped).toEqual([]); }); it("coalesces concurrent codex model discovery requests", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1f0213e4d..e5e5a650e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -140,6 +140,7 @@ import type { AgentChatNoticeDetail, AgentChatInteractionMode, AgentChatInterruptArgs, + AgentChatCursorModelSource, AgentChatModelCatalog, AgentChatModelCatalogArgs, AgentChatModelCatalogRefreshProvider, @@ -23287,34 +23288,74 @@ export function createAgentChatService(args: { ? MODEL_CATALOG_LOCAL_REFRESH_TTL_MS : MODEL_CATALOG_REFRESH_TTL_MS; + // Cursor freshness is tracked per discovery source: an SDK-scoped refresh + // only proves the SDK rows are current, so it must not mark the CLI surface + // fresh (a later Work-tab CLI picker would otherwise skip its force probe + // for the TTL and miss CLI-only model/auth changes), and vice versa. + const cursorCatalogSourceRefreshedAt = new Map<"sdk" | "cli", number>(); + const cursorSourcesFor = (cursorSource?: AgentChatCursorModelSource): ("sdk" | "cli")[] => + cursorSource === "sdk" ? ["sdk"] : cursorSource === "cli" ? ["cli"] : ["sdk", "cli"]; + const markModelCatalogProviderFresh = ( refreshProvider: AgentChatModelCatalogRefreshProvider | undefined, refreshedAt: number, + cursorSource?: AgentChatCursorModelSource, ): void => { + if (refreshProvider === "cursor") { + for (const source of cursorSourcesFor(cursorSource)) { + cursorCatalogSourceRefreshedAt.set(source, refreshedAt); + } + return; + } if (refreshProvider) { modelCatalogProviderRefreshedAt.set(refreshProvider, refreshedAt); return; } for (const provider of MODEL_CATALOG_REFRESH_PROVIDERS) { - modelCatalogProviderRefreshedAt.set(provider, refreshedAt); + if (provider === "cursor") { + cursorCatalogSourceRefreshedAt.set("sdk", refreshedAt); + cursorCatalogSourceRefreshedAt.set("cli", refreshedAt); + } else { + modelCatalogProviderRefreshedAt.set(provider, refreshedAt); + } } }; const modelCatalogContainsRefreshProvider = ( catalog: AgentChatModelCatalog, provider: AgentChatModelCatalogRefreshProvider, + cursorSource?: AgentChatCursorModelSource, ): boolean => { return (catalog.groups ?? []).some((group) => { const groupMatches = group.key === provider; if (!groupMatches) return false; + // A cursor-flavored check must see rows the requesting surface can run: + // an SDK-only refresh must not satisfy a CLI-surface staleness probe. + if (provider === "cursor" && (cursorSource === "sdk" || cursorSource === "cli")) { + return (group.providers ?? []).some((entry) => + (entry.subsections ?? []).some((subsection) => + (subsection.models ?? []).some((model) => model.cursorAvailability?.[cursorSource] === true))); + } return (group.providers ?? []).some((entry) => entry.modelCount > 0); }); }; - const isModelCatalogRefreshStale = (refreshProvider?: AgentChatModelCatalogRefreshProvider): boolean => { + const isModelCatalogRefreshStale = ( + refreshProvider?: AgentChatModelCatalogRefreshProvider, + cursorSource?: AgentChatCursorModelSource, + ): boolean => { if (!modelCatalogCache) return true; + if (refreshProvider === "cursor") { + if (!modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider, cursorSource)) return true; + // Stale unless every source the request covers was itself refreshed + // within the TTL — an "all" request needs both sdk and cli fresh. + const ttl = modelCatalogRefreshTtlMs(refreshProvider); + return cursorSourcesFor(cursorSource).some((source) => { + const refreshedAt = cursorCatalogSourceRefreshedAt.get(source); + return !refreshedAt || Date.now() - refreshedAt > ttl; + }); + } if (refreshProvider) { - if (refreshProvider === "cursor" && !modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider)) return true; const refreshedAt = modelCatalogProviderRefreshedAt.get(refreshProvider); return !refreshedAt || Date.now() - refreshedAt > modelCatalogRefreshTtlMs(refreshProvider); } @@ -23333,10 +23374,11 @@ export function createAgentChatService(args: { const shouldMarkModelCatalogProviderFresh = ( catalog: AgentChatModelCatalog, refreshProvider: AgentChatModelCatalogRefreshProvider | undefined, + cursorSource?: AgentChatCursorModelSource, ): boolean => { if (!refreshProvider) return true; if (refreshProvider !== "cursor") return true; - return modelCatalogContainsRefreshProvider(catalog, refreshProvider); + return modelCatalogContainsRefreshProvider(catalog, refreshProvider, cursorSource); }; const discoverOpenCodeLocalModels = async (): Promise => { @@ -23368,6 +23410,7 @@ export function createAgentChatService(args: { const loadAvailableModels = async (args: { provider: AgentChatProvider; activateRuntime?: boolean; + cursorSource?: AgentChatCursorModelSource; }): Promise => { const provider = args.provider; if (provider === "codex") { @@ -23387,19 +23430,40 @@ export function createAgentChatService(args: { const cursorCliPath = cursorCli?.type === "cli-subscription" && cursorCli.cli === "cursor" ? cursorCli.path : null; + // Probe only the source the requesting surface runs models through — + // chat surfaces use the SDK (~300ms), CLI lane drafts use the CLI + // (a process spawn that can take seconds). The other source serves + // last-known-good rows and revalidates in the background, so a chat + // picker refresh never waits on a CLI spawn. + const cursorSource = args.cursorSource ?? "all"; + const probeCli = args.activateRuntime === true && cursorSource !== "sdk"; + const probeSdk = args.activateRuntime === true && cursorSource !== "cli"; const [cliDescriptors, sdkDescriptors] = await Promise.all([ cursorCliPath ? discoverCursorCliModelDescriptors(cursorCliPath, { - mode: args.activateRuntime ? "probe" : "cached-or-fallback", + mode: probeCli ? "probe" : "cached-or-fallback", }).catch(() => []) : Promise.resolve([]), apiKey ? discoverCursorSdkModelDescriptors(apiKey, { - mode: args.activateRuntime ? "probe" : "cached-only", + mode: probeSdk ? "probe" : "cached-or-fallback", }).catch(() => []) : Promise.resolve([]), ]); - const ordered = mergeCursorModelDescriptorSources({ cliDescriptors, sdkDescriptors }); + const merged = mergeCursorModelDescriptorSources({ cliDescriptors, sdkDescriptors }); + // Honor the requested source in the RETURNED set, not just in probing. + // A surface that runs Cursor through one runtime must not be offered + // models only the other runtime can run — selecting one would later + // throw in assertCursorChatModelCanUseSdk (or the CLI equivalent). The + // skipped source still annotates availability on dual-capable rows; + // only rows exclusive to the skipped source are dropped. "all" returns + // the union. Surfaces that don't filter client-side (some TUI/mobile + // paths) rely on this. + const ordered = cursorSource === "sdk" + ? merged.filter((d) => d.cursorAvailability?.sdk === true) + : cursorSource === "cli" + ? merged.filter((d) => d.cursorAvailability?.cli === true) + : merged; const preferred = pickDefaultCursorDescriptorFromCliList(ordered); return ordered.map((d) => ({ id: d.id, @@ -23571,17 +23635,19 @@ export function createAgentChatService(args: { const getAvailableModels = async ({ provider, activateRuntime, + cursorSource, }: { provider: AgentChatProvider; activateRuntime?: boolean; + cursorSource?: AgentChatCursorModelSource; }): Promise => { - const requestKey = `${provider}:${activateRuntime === true ? "active" : "passive"}`; + const requestKey = `${provider}:${activateRuntime === true ? "active" : "passive"}:${cursorSource ?? "all"}`; const existingRequest = availableModelsRequests.get(requestKey); if (existingRequest) { return existingRequest; } - const request = loadAvailableModels({ provider, activateRuntime }); + const request = loadAvailableModels({ provider, activateRuntime, cursorSource }); availableModelsRequests.set(requestKey, request); try { return await request; @@ -23595,7 +23661,7 @@ export function createAgentChatService(args: { const modelCatalogRequestKey = (catalogArgs?: AgentChatModelCatalogArgs): string => { const mode = catalogArgs?.mode ?? "refresh-stale"; const refreshProvider = catalogArgs?.refreshProvider; - return `${mode}:${refreshProvider ?? "all"}`; + return `${mode}:${refreshProvider ?? "all"}:${catalogArgs?.cursorSource ?? "all"}`; }; const buildModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise => { @@ -23624,6 +23690,9 @@ export function createAgentChatService(args: { activateRuntime: (provider === "cursor" && shouldRefreshProvider("cursor")) || (provider === "droid" && shouldRefreshProvider("droid")), + ...(provider === "cursor" && catalogArgs?.cursorSource + ? { cursorSource: catalogArgs.cursorSource } + : {}), }), }; } catch { @@ -23802,8 +23871,8 @@ export function createAgentChatService(args: { })), }; modelCatalogCache = catalog; - if (mode !== "cached" && shouldMarkModelCatalogProviderFresh(catalog, refreshProvider)) { - markModelCatalogProviderFresh(refreshProvider, Date.now()); + if (mode !== "cached" && shouldMarkModelCatalogProviderFresh(catalog, refreshProvider, catalogArgs?.cursorSource)) { + markModelCatalogProviderFresh(refreshProvider, Date.now(), catalogArgs?.cursorSource); } return catalog; }; @@ -23833,11 +23902,12 @@ export function createAgentChatService(args: { const getModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise => { const mode = catalogArgs?.mode ?? "refresh-stale"; if (mode === "refresh-stale" && modelCatalogCache) { - const stale = isModelCatalogRefreshStale(catalogArgs?.refreshProvider); + const stale = isModelCatalogRefreshStale(catalogArgs?.refreshProvider, catalogArgs?.cursorSource); if (stale) { scheduleModelCatalogRefresh({ mode: "force", ...(catalogArgs?.refreshProvider ? { refreshProvider: catalogArgs.refreshProvider } : {}), + ...(catalogArgs?.cursorSource ? { cursorSource: catalogArgs.cursorSource } : {}), }); } return withModelCatalogStaleFlag(modelCatalogCache, stale); diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts index 30df9becc..2be597913 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts @@ -35,6 +35,7 @@ import { discoverCursorSdkModelDescriptors, listCursorModelsFromCli, listCursorModelsFromSdk, + markCursorModelCachesStale, mergeCursorModelDescriptorSources, parseCursorCliModelsStdout, probeCursorSdkModelDiscovery, @@ -609,6 +610,30 @@ describe("parseCursorCliModelsStdout", () => { expect(freshProbe.fromCache).toBeUndefined(); }); + it("reflects an authoritative empty Cursor probe instead of stale cached rows", async () => { + // Warm the cache with a model via a successful probe. + cursorModelsListMock.mockResolvedValueOnce([{ id: "cached-model", displayName: "Cached Model" }]); + await expect(probeCursorSdkModelDiscovery("crsr_test", { timeoutMs: 1_000 })).resolves.toMatchObject({ + rows: [{ id: "cached-model" }], + failureKind: null, + }); + + // A later probe SUCCEEDS but the account now has no models. Both the SDK + // and the official-API fallback return empty without error, so failureKind + // stays null — an authoritative empty result, not a transient failure. + cursorModelsListMock.mockResolvedValue([]); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ models: [] }) }))); + + const descriptors = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" }); + expect(descriptors).toEqual([]); + + // The authoritative empty probe must also drop the stale SDK cache, so a + // later passive cached-or-fallback read (status/mobile/TUI) reflects the + // empty result instead of resurrecting the warmed rows. + const passive = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "cached-or-fallback" }); + expect(passive).toEqual([]); + }); + it("suppresses warmed Cursor SDK cache after an SDK runtime failure", async () => { cursorModelsListMock.mockResolvedValueOnce([{ id: "cached-model", displayName: "Cached Model" }]); await expect(probeCursorSdkModelDiscovery("crsr_test", { timeoutMs: 1_000 })).resolves.toMatchObject({ @@ -661,4 +686,49 @@ describe("parseCursorCliModelsStdout", () => { await expect(rowsPromise).resolves.toEqual([]); }); + + it("serves last-known-good CLI rows past the freshness window after the cache is aged", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T00:00:00.000Z")); + spawnAsyncMock.mockResolvedValueOnce({ status: 0, stdout: "auto - Auto\n", stderr: "" }); + + const fresh = await listCursorModelsFromCli("/usr/local/bin/cursor-agent"); + expect(fresh.map((row) => row.id)).toEqual(["auto"]); + expect(spawnAsyncMock).toHaveBeenCalledTimes(1); + + // Generic readiness invalidation ages the cache instead of dropping it. + markCursorModelCachesStale(); + spawnAsyncMock.mockReset(); + + // Past the 120s freshness window, a transient probe failure must still + // surface the last-known-good rows rather than blanking the model list. + vi.setSystemTime(new Date("2026-06-10T00:05:00.000Z")); + spawnAsyncMock.mockRejectedValue(new Error("cursor-agent spawn failed")); + + const afterStale = await listCursorModelsFromCli("/usr/local/bin/cursor-agent"); + expect(afterStale.map((row) => row.id)).toEqual(["auto"]); + expect(reportProviderRuntimeFailureMock).not.toHaveBeenCalled(); + }); + + it("drops CLI rows and reports a provider failure when the signed-in CLI has no model access", async () => { + spawnAsyncMock.mockResolvedValueOnce({ status: 0, stdout: "auto - Auto\n", stderr: "" }); + const seeded = await listCursorModelsFromCli("/usr/local/bin/cursor-agent"); + expect(seeded.map((row) => row.id)).toEqual(["auto"]); + + // Age the cache so the next call re-probes the CLI instead of short-circuiting. + markCursorModelCachesStale(); + spawnAsyncMock.mockReset(); + spawnAsyncMock.mockResolvedValue({ status: 0, stdout: "No models available for this account", stderr: "" }); + + const afterNoAccess = await listCursorModelsFromCli("/usr/local/bin/cursor-agent"); + expect(afterNoAccess).toEqual([]); + expect(reportProviderRuntimeFailureMock).toHaveBeenCalledWith( + "cursor", + expect.stringContaining("reports no available models"), + ); + // The dead login must not keep resurfacing stale rows on the next read. + expect( + await discoverCursorCliModelDescriptors("/usr/local/bin/cursor-agent", { mode: "cached-only" }), + ).toEqual([]); + }); }); diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index f2659b972..c417096a3 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -48,11 +48,18 @@ export type CursorSdkModelDiscoveryResult = { }; let cached: { at: number; models: CursorCliModelRow[] } | null = null; +let cliWarmInFlight: Promise | null = null; +let cliLastWarmAttemptAt = 0; let sdkCached: { at: number; keyHash: string; models: CursorCliModelRow[] } | null = null; let sdkWarmInFlight: { keyHash: string; promise: Promise } | null = null; let sdkLastFailure: { at: number; keyHash: string; kind: CursorModelDiscoveryFailureKind; message: string } | null = null; let sdkCacheGeneration = 0; const TTL_MS = 120_000; +// Serve last-known-good rows well past the freshness window (revalidating in +// the background) so passive consumers — status/availableModelIds, the mobile +// app, the TUI — never watch a verified provider's models blink out two +// minutes after the last active probe. +const POSITIVE_TTL_MS = 6 * 60 * 60_000; const SDK_MODEL_LIST_TIMEOUT_MS = 5_000; const CURSOR_MODELS_API_URL = "https://api.cursor.com/v0/models"; const CURSOR_AGENT_AUTH_BLOCKER = @@ -291,12 +298,30 @@ function foldCursorCliVariantRows(rows: CursorCliModelRow[]): CursorCliModelRow[ export function clearCursorCliModelsCache(): void { cached = null; + cliWarmInFlight = null; + cliLastWarmAttemptAt = 0; sdkCached = null; sdkWarmInFlight = null; sdkLastFailure = null; sdkCacheGeneration += 1; } +/** + * Age the positive model caches so the next consumer revalidates in the + * background, without dropping last-known-good rows. Generic provider + * readiness invalidation (forced status refresh, verifying another provider's + * key) must not blank the cursor model list — only a cursor key change does + * that ({@link clearCursorCliModelsCache}). A stale SDK row set for a + * different key is already unreachable because the cache is key-hash gated. + */ +export function markCursorModelCachesStale(): void { + const stalePoint = Date.now() - TTL_MS; + if (cached) cached = { ...cached, at: Math.min(cached.at, stalePoint) }; + if (sdkCached) sdkCached = { ...sdkCached, at: Math.min(sdkCached.at, stalePoint) }; + sdkLastFailure = null; + cliLastWarmAttemptAt = 0; +} + function hashKeyForCache(key: string | null | undefined): string { const text = String(key ?? ""); return createHash("sha256").update(text).digest("hex").slice(0, 16); @@ -548,6 +573,11 @@ function rememberCursorModelDiscoveryFailure(keyHash: string, error: unknown): C kind: discoveryError.kind, message: discoveryError.message, }; + // Auth failures mean the key is dead: last-known-good rows for that key + // must not keep resurfacing. Transient failures keep serving them. + if (discoveryError.kind === "auth" && sdkCached?.keyHash === keyHash) { + sdkCached = null; + } return discoveryError; } @@ -631,21 +661,26 @@ function getCachedCursorSdkModels(apiKey?: string | null): CursorCliModelRow[] | const now = Date.now(); const normalizedApiKey = apiKey?.trim() || undefined; const keyHash = hashKeyForCache(normalizedApiKey); - if (hasRecentCursorSdkFailure(keyHash, now)) { - return null; - } - if (sdkCached && sdkCached.keyHash === keyHash && now - sdkCached.at < TTL_MS && sdkCached.models.length) { + if (sdkCached && sdkCached.keyHash === keyHash && now - sdkCached.at < POSITIVE_TTL_MS && sdkCached.models.length) { + // Stale-while-revalidate: keep serving last-known-good rows and refresh + // in the background once the freshness window has passed. A transient + // failure recorded after a successful fetch must not blank the list. + if (now - sdkCached.at >= TTL_MS) { + warmCursorModelsFromSdk(normalizedApiKey); + } return sdkCached.models; } return null; } function hasRecentCursorSdkFailure(keyHash: string, now = Date.now()): boolean { + // Gates background warms only (active probes always run): any recent + // failure kind — including timeouts — defers the next warm attempt so + // passive consumers don't retry a 5s fetch on every call. return Boolean( sdkLastFailure && sdkLastFailure.keyHash === keyHash - && now - sdkLastFailure.at < TTL_MS - && (sdkLastFailure.kind === "auth" || sdkLastFailure.kind === "unavailable"), + && now - sdkLastFailure.at < TTL_MS, ); } @@ -684,6 +719,9 @@ async function fetchCursorModelsFromSdk( } if (sdkError && isCursorSdkResolutionError(sdkError)) { + // The SDK module itself is unusable (packaging bug), so chats cannot + // run — drop last-known-good rows rather than advertise phantom models. + sdkCached = null; throw toCursorModelDiscoveryError(sdkError); } @@ -704,6 +742,13 @@ async function fetchCursorModelsFromSdk( })(), timeoutMs); if (rows.length && generation === sdkCacheGeneration) { sdkCached = { at: Date.now(), keyHash, models: rows }; + } else if (!rows.length && generation === sdkCacheGeneration && sdkCached?.keyHash === keyHash) { + // Authoritative empty result: the fetch returned without throwing, so both + // the SDK and the official API reported zero models for this key (timeouts + // and auth/transient failures throw and never reach here). Drop the stale + // rows so passive cached-or-fallback readers (status/mobile/TUI) don't + // resurrect models the provider just reported as gone. + sdkCached = null; } if (sdkSucceeded && rows.length && sdkLastFailure?.keyHash === keyHash) { sdkLastFailure = null; @@ -803,14 +848,30 @@ export async function listCursorModelsFromSdk( return (await probeCursorSdkModelDiscovery(apiKey, options)).rows; } -function getCachedCursorModels(): CursorCliModelRow[] | null { +function getCachedCursorModels(agentPathForRevalidate?: string | null): CursorCliModelRow[] | null { const now = Date.now(); - if (cached && now - cached.at < TTL_MS && cached.models.length) { + if (cached && now - cached.at < POSITIVE_TTL_MS && cached.models.length) { + if (now - cached.at >= TTL_MS && agentPathForRevalidate) { + warmCursorModelsFromCli(agentPathForRevalidate); + } return cached.models; } return null; } +function warmCursorModelsFromCli(agentPath: string): void { + const now = Date.now(); + // At most one background CLI probe per freshness window — without this, a + // broken or missing CLI gets re-spawned on every passive discovery call. + if (cliWarmInFlight || now - cliLastWarmAttemptAt < TTL_MS) return; + cliLastWarmAttemptAt = now; + const promise = listCursorModelsFromCli(agentPath).catch(() => [] as CursorCliModelRow[]); + cliWarmInFlight = promise; + void promise.finally(() => { + if (cliWarmInFlight === promise) cliWarmInFlight = null; + }); +} + function normalizeCursorModelLookupRef(value: unknown): string { const raw = String(value ?? "").trim(); const withoutPrefix = raw.toLowerCase().startsWith("cursor/") @@ -1068,12 +1129,17 @@ export async function listCursorModelsFromCli(agentPath: string): Promise { - const rows = options?.mode === "cached-or-fallback" || options?.mode === "cached-only" - ? getCachedCursorModels() ?? [] + const cachedMode = options?.mode === "cached-or-fallback" || options?.mode === "cached-only"; + const rows = cachedMode + ? getCachedCursorModels(options?.mode === "cached-or-fallback" ? agentPath : null) ?? [] : await listCursorModelsFromCli(agentPath); + if (!rows.length && options?.mode === "cached-or-fallback") { + warmCursorModelsFromCli(agentPath); + } return cursorRowsToDescriptors(rows); } @@ -1122,7 +1205,17 @@ export async function discoverCursorSdkModelDescriptors( const result = options?.mode === "probe" ? await probeCursorSdkModelDiscovery(apiKey, { timeoutMs: options?.timeoutMs }) : null; - const rows = result?.rows ?? getCachedCursorSdkModels(apiKey) ?? []; + let rows = result?.rows ?? []; + // Fall back to last-known-good rows for the cached modes (result == null) and + // for transient probe failures. A SUCCESSFUL but empty probe (failureKind + // == null) is authoritative — reflect the empty result rather than + // advertising models the provider just reported as gone. An auth failure + // means the key is dead, so its stale rows must not resurface either. + const probeFailedTransiently = + result != null && result.failureKind != null && result.failureKind !== "auth"; + if (!rows.length && (result == null || probeFailedTransiently)) { + rows = getCachedCursorSdkModels(apiKey) ?? []; + } if (!rows.length && options?.mode === "cached-or-fallback") { warmCursorModelsFromSdk(apiKey); } diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts index 7a44e1e9f..8c8370877 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts @@ -21,10 +21,20 @@ vi.mock("node:os", async (importOriginal) => { import { clearDroidCliModelsCache, discoverDroidCliModelDescriptors, + markDroidModelCachesStale, parseDroidExecHelpModelIds, parseDroidExecHelpModels, } from "./droidModelsDiscovery"; +function sessionWithModels(ids: string[]) { + return { + initResult: { + availableModels: ids.map((id) => ({ id, displayName: id })), + }, + close: vi.fn(async () => {}), + }; +} + let tmpHome: string; beforeEach(() => { @@ -193,4 +203,36 @@ describe("discoverDroidCliModelDescriptors", () => { customProxy: true, }); }); + + it("serves last-known-good rows past the freshness window and revalidates once in the background", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T00:00:00.000Z")); + try { + mockCreateSession.mockResolvedValueOnce(sessionWithModels(["claude-sonnet-4-6"])); + const seeded = await discoverDroidCliModelDescriptors("/mock/bin/droid"); + expect(seeded.map((d) => d.id)).toEqual(["droid/claude-sonnet-4-6"]); + expect(mockCreateSession).toHaveBeenCalledTimes(1); + + // Generic readiness invalidation ages the cache without dropping rows. + markDroidModelCachesStale(); + // The background revalidation fails (e.g. droid is mid-reauth), so the + // aged last-known-good rows must remain the served answer. + mockCreateSession.mockRejectedValue(new Error("droid session unavailable")); + + // A passive read past the 120s window still returns the cached rows + // synchronously and kicks off exactly one background SDK session. + const stale = await discoverDroidCliModelDescriptors("/mock/bin/droid", { mode: "cached-or-fallback" }); + expect(stale.map((d) => d.id)).toEqual(["droid/claude-sonnet-4-6"]); + expect(mockCreateSession).toHaveBeenCalledTimes(2); + + // Backoff: a second passive read inside the same freshness window must + // NOT spawn another session, even though the cache is still aged and the + // warm just failed — without backoff a broken droid gets a session per call. + const again = await discoverDroidCliModelDescriptors("/mock/bin/droid", { mode: "cached-or-fallback" }); + expect(again.map((d) => d.id)).toEqual(["droid/claude-sonnet-4-6"]); + expect(mockCreateSession).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index a678cec86..c22d272f9 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -22,7 +22,12 @@ type DroidCliModelDiscoveryMode = "probe" | "cached-or-fallback"; let cached: { at: number; models: DroidExecHelpModelRow[] } | null = null; let inflight: Promise | null = null; +let warmInFlight = false; +let lastWarmAttemptAt = 0; const TTL_MS = 120_000; +// Serve last-known-good rows well past the freshness window (revalidating in +// the background) so passive consumers never lose models between probes. +const POSITIVE_TTL_MS = 6 * 60 * 60_000; export function parseDroidExecHelpModels(stdout: string): DroidExecHelpModelRow[] { const lines = stdout.split(/\r?\n/); @@ -264,13 +269,49 @@ async function listDroidModelsFromSdk(droidPath: string): Promise undefined) + .finally(() => { + warmInFlight = false; + }); +} + +function getCachedDroidModels(droidPathForRevalidate?: string | null): DroidExecHelpModelRow[] | null { const now = Date.now(); if (cached && now - cached.at < TTL_MS) { return cached.models; } + // Stale-while-revalidate: serve last-known-good rows past the freshness + // window and refresh in the background so passive consumers never watch + // models blink out between active probes. + if (cached && now - cached.at < POSITIVE_TTL_MS && cached.models.length) { + if (droidPathForRevalidate) warmDroidModels(droidPathForRevalidate); + return cached.models; + } return null; } @@ -311,8 +352,11 @@ export async function discoverDroidCliModelDescriptors( options?: { mode?: DroidCliModelDiscoveryMode }, ): Promise { const fromSdk = options?.mode === "cached-or-fallback" - ? getCachedDroidModels() ?? [] + ? getCachedDroidModels(droidPath) ?? [] : await listDroidModelsFromSdk(droidPath).catch(() => []); + if (!fromSdk.length && options?.mode === "cached-or-fallback") { + warmDroidModels(droidPath); + } const baseRows: DroidExecHelpModelRow[] = fromSdk; // Merge custom models from ~/.factory/config.json so vibeproxy-injected diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 44cd5d75e..d12c07f09 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -734,7 +734,7 @@ describe("ModelPicker", () => { await user.click(cursorRail); await waitFor(() => { - expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "cursor" }); + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "cursor", cursorSource: "sdk" }); }); expect(await screen.findByText("No Cursor models found")).toBeTruthy(); expect(screen.queryByText("Connect Cursor")).toBeNull(); @@ -743,7 +743,7 @@ describe("ModelPicker", () => { await user.click(cursorRail); await waitFor(() => { - expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "cursor" }); + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "cursor", cursorSource: "sdk" }); }); }); @@ -755,8 +755,25 @@ describe("ModelPicker", () => { providers: [{ key: "cursor", displayName: "Cursor", + badgeColor: "#60A5FA", modelCount: 1, - subsections: [], + subsections: [{ + key: "cursor", + label: "Cursor", + models: [{ + id: "cursor/composer-2", + runtimeModelId: "cursor/composer-2", + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: "Composer 2", + isDefault: true, + isAvailable: true, + supportsReasoning: true, + supportsTools: true, + cursorAvailability: { sdk: true, cli: true }, + }], + }], }], }], fetchedAt: "2026-05-18T00:00:00.000Z", diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index b3548a9e0..b8dd1a1fd 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -86,10 +86,21 @@ export const ModelPicker = memo(function ModelPicker({ const [refreshingProvider, setRefreshingProvider] = useState(null); const { recents } = useModelRecents({ hydrate: open }); + // Which cursor discovery source this picker surface needs synchronously: + // chat surfaces run models through the SDK, CLI lane drafts through the + // cursor-agent CLI. The host probes only this source and lets the other + // revalidate in the background, so a chat refresh never waits on a CLI spawn. + const cursorSource = cursorAvailabilityMode === "cli" + ? "cli" as const + : cursorAvailabilityMode === "all" + ? undefined + : "sdk" as const; + const loadRuntimeCatalog = useCallback(async (args: { mode: "cached" | "refresh-stale" | "force"; refreshProvider?: AgentChatModelCatalogRefreshProvider; }): Promise => { + const cursorFlavor = args.refreshProvider === "cursor" ? cursorSource : undefined; const shared = getSharedRuntimeCatalog(); if (args.mode === "cached" && shared) { setRuntimeCatalog(shared); @@ -97,14 +108,14 @@ export const ModelPicker = memo(function ModelPicker({ } if (args.mode === "refresh-stale" && args.refreshProvider && shared) { setRuntimeCatalog(shared); - if (runtimeCatalogProviderIsFresh(args.refreshProvider)) { + if (runtimeCatalogProviderIsFresh(args.refreshProvider, cursorFlavor)) { return { ...shared, stale: false }; } } const bridge = window.ade?.agentChat?.modelCatalog; if (typeof bridge !== "function") return null; - const requestKey = `${args.mode}:${args.refreshProvider ?? "all"}`; + const requestKey = `${args.mode}:${args.refreshProvider ?? "all"}:${cursorFlavor ?? "all"}`; const existingRequest = getRuntimeCatalogRequest(requestKey); if (existingRequest) { const next = await existingRequest; @@ -114,8 +125,14 @@ export const ModelPicker = memo(function ModelPicker({ const request = (async () => { try { - const next = await bridge(args); - const visible = rememberRuntimeCatalog(next, args); + const next = await bridge({ + ...args, + ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), + }); + const visible = rememberRuntimeCatalog(next, { + ...args, + ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), + }); setRuntimeCatalog(visible); return visible; } catch { @@ -128,7 +145,7 @@ export const ModelPicker = memo(function ModelPicker({ clearRuntimeCatalogRequest(requestKey, request); }); return await request; - }, []); + }, [cursorSource]); useEffect(() => { if (!open) return; @@ -150,10 +167,11 @@ export const ModelPicker = memo(function ModelPicker({ : null; if (refreshProvider) { void (async () => { + const cursorFlavor = refreshProvider === "cursor" ? cursorSource : undefined; const shared = getSharedRuntimeCatalog(); if (shared) { setRuntimeCatalog(shared); - if (runtimeCatalogProviderIsFresh(refreshProvider)) return; + if (runtimeCatalogProviderIsFresh(refreshProvider, cursorFlavor)) return; } setRefreshingProvider(refreshProvider); try { @@ -167,7 +185,7 @@ export const ModelPicker = memo(function ModelPicker({ } })(); } - }, [loadRuntimeCatalog, onRuntimeCatalogRefreshed]); + }, [cursorSource, loadRuntimeCatalog, onRuntimeCatalogRefreshed]); const catalogModels = useMemo( () => descriptorsFromAgentChatModelCatalog(runtimeCatalog, filter), diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts new file mode 100644 index 000000000..174788b57 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + rememberRuntimeCatalog, + resetModelPickerRuntimeCatalogForTests, + runtimeCatalogProviderIsFresh, +} from "./runtimeCatalogCache"; +import type { AgentChatModelCatalog } from "../../../../shared/types"; + +function cursorCatalog(availability: { sdk: boolean; cli: boolean }): AgentChatModelCatalog { + return { + fetchedAt: new Date().toISOString(), + groups: [ + { + key: "cursor", + displayName: "Cursor", + providers: [ + { + key: "cursor", + displayName: "Cursor", + badgeColor: "#60A5FA", + modelCount: 1, + subsections: [ + { + key: "cursor", + label: "Cursor", + models: [ + { + id: "cursor/composer-2", + runtimeModelId: "cursor/composer-2", + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: "Composer 2", + isDefault: true, + isAvailable: true, + supportsReasoning: true, + supportsTools: true, + cursorAvailability: availability, + }, + ], + }, + ], + }, + ], + }, + ], + }; +} + +describe("runtimeCatalogCache flavor-aware cursor freshness", () => { + beforeEach(() => { + resetModelPickerRuntimeCatalogForTests(); + }); + + it("does not let an SDK-only refresh satisfy a CLI-surface freshness check", () => { + // A chat surface refreshed cursor through the SDK; only SDK rows are + // available in the cached catalog. + rememberRuntimeCatalog(cursorCatalog({ sdk: true, cli: false }), { + mode: "force", + refreshProvider: "cursor", + }); + + // The SDK surface sees its models as fresh... + expect(runtimeCatalogProviderIsFresh("cursor", "sdk")).toBe(true); + // ...but a CLI-flavored surface must still treat cursor as stale, because + // none of the cached rows are runnable through the cursor-agent CLI. + expect(runtimeCatalogProviderIsFresh("cursor", "cli")).toBe(false); + // The flavor-agnostic ("all") check needs BOTH sources fresh, so it is + // stale too — only the SDK source was refreshed. + expect(runtimeCatalogProviderIsFresh("cursor")).toBe(false); + }); + + it("an sdk-scoped refresh leaves the cli surface stale even with dual-capable rows", () => { + // The catalog carries dual-capable rows (cli AND sdk), but the refresh + // only probed the SDK source. The CLI surface must stay stale so a later + // Work-tab CLI picker still forces its own probe (per-source freshness), + // rather than trusting the SDK refresh because dual rows happen to be cli. + rememberRuntimeCatalog(cursorCatalog({ sdk: true, cli: true }), { + mode: "force", + refreshProvider: "cursor", + cursorSource: "sdk", + }); + + expect(runtimeCatalogProviderIsFresh("cursor", "sdk")).toBe(true); + expect(runtimeCatalogProviderIsFresh("cursor", "cli")).toBe(false); + }); + + it("treats both surfaces as fresh once the catalog carries CLI and SDK rows", () => { + rememberRuntimeCatalog(cursorCatalog({ sdk: true, cli: true }), { + mode: "force", + refreshProvider: "cursor", + }); + + expect(runtimeCatalogProviderIsFresh("cursor", "sdk")).toBe(true); + expect(runtimeCatalogProviderIsFresh("cursor", "cli")).toBe(true); + expect(runtimeCatalogProviderIsFresh("cursor")).toBe(true); + }); + + it("reports cursor stale for every flavor before any catalog is cached", () => { + expect(runtimeCatalogProviderIsFresh("cursor", "sdk")).toBe(false); + expect(runtimeCatalogProviderIsFresh("cursor", "cli")).toBe(false); + expect(runtimeCatalogProviderIsFresh("cursor")).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index 21340e732..b6060d1bc 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -12,11 +12,16 @@ const REFRESH_PROVIDERS: AgentChatModelCatalogRefreshProvider[] = [ let sharedRuntimeCatalog: AgentChatModelCatalog | null = null; const sharedRuntimeCatalogProviderRefreshedAt = new Map(); +// Cursor freshness is per discovery source: an SDK-scoped refresh must not +// mark the CLI surface fresh (a later Work-tab CLI picker would otherwise +// short-circuit its force refresh for the TTL and miss CLI-only changes). +const cursorSourceRefreshedAt = new Map<"sdk" | "cli", number>(); const sharedRuntimeCatalogRequests = new Map>(); export function resetModelPickerRuntimeCatalogForTests(): void { sharedRuntimeCatalog = null; sharedRuntimeCatalogProviderRefreshedAt.clear(); + cursorSourceRefreshedAt.clear(); sharedRuntimeCatalogRequests.clear(); } @@ -33,12 +38,20 @@ function runtimeCatalogRefreshTtlMs(provider?: AgentChatModelCatalogRefreshProvi function catalogContainsRefreshProvider( catalog: AgentChatModelCatalog, provider: AgentChatModelCatalogRefreshProvider, + cursorFlavor?: "sdk" | "cli", ): boolean { return (catalog.groups ?? []).some((group) => { const groupMatches = provider === "droid" ? group.key === "droid" : group.key === provider; if (!groupMatches) return false; + // A cursor-flavored check must see rows the requesting surface can run: + // an SDK-only refresh must not satisfy a CLI-surface freshness check. + if (provider === "cursor" && cursorFlavor) { + return (group.providers ?? []).some((entry) => + (entry.subsections ?? []).some((subsection) => + (subsection.models ?? []).some((model) => model.cursorAvailability?.[cursorFlavor] === true))); + } return (group.providers ?? []).some((entry) => entry.modelCount > 0); }); } @@ -46,29 +59,59 @@ function catalogContainsRefreshProvider( function shouldMarkRefreshProviderFresh( catalog: AgentChatModelCatalog, provider: AgentChatModelCatalogRefreshProvider, + cursorFlavor?: "sdk" | "cli", ): boolean { if (provider !== "cursor") return true; - return catalogContainsRefreshProvider(catalog, provider); + return catalogContainsRefreshProvider(catalog, provider, cursorFlavor); } function markRuntimeCatalogProviderFresh( provider: AgentChatModelCatalogRefreshProvider, refreshedAt = Date.now(), + cursorFlavor?: "sdk" | "cli", ): void { + if (provider === "cursor") { + const sources: ("sdk" | "cli")[] = cursorFlavor ? [cursorFlavor] : ["sdk", "cli"]; + for (const source of sources) { + // Without an explicit flavor (generic cached-reuse marking), only mark a + // source fresh if the catalog actually carries rows it can run, so an + // sdk-only catalog never marks the cli surface fresh. + if (!cursorFlavor && sharedRuntimeCatalog && !catalogContainsRefreshProvider(sharedRuntimeCatalog, "cursor", source)) { + continue; + } + cursorSourceRefreshedAt.set(source, refreshedAt); + } + return; + } sharedRuntimeCatalogProviderRefreshedAt.set(provider, refreshedAt); } -export function runtimeCatalogProviderIsFresh(provider: AgentChatModelCatalogRefreshProvider): boolean { - const refreshedAt = sharedRuntimeCatalogProviderRefreshedAt.get(provider); - if (provider === "cursor" && (!sharedRuntimeCatalog || !catalogContainsRefreshProvider(sharedRuntimeCatalog, provider))) { - return false; +export function runtimeCatalogProviderIsFresh( + provider: AgentChatModelCatalogRefreshProvider, + cursorFlavor?: "sdk" | "cli", +): boolean { + if (provider === "cursor") { + if (!sharedRuntimeCatalog || !catalogContainsRefreshProvider(sharedRuntimeCatalog, provider, cursorFlavor)) { + return false; + } + const sources: ("sdk" | "cli")[] = cursorFlavor ? [cursorFlavor] : ["sdk", "cli"]; + const ttl = runtimeCatalogRefreshTtlMs(provider); + return sources.every((source) => { + const at = cursorSourceRefreshedAt.get(source); + return Boolean(at && Date.now() - at <= ttl); + }); } + const refreshedAt = sharedRuntimeCatalogProviderRefreshedAt.get(provider); return Boolean(refreshedAt && Date.now() - refreshedAt <= runtimeCatalogRefreshTtlMs(provider)); } export function rememberRuntimeCatalog( catalog: AgentChatModelCatalog, - args: { mode: "cached" | "refresh-stale" | "force"; refreshProvider?: AgentChatModelCatalogRefreshProvider }, + args: { + mode: "cached" | "refresh-stale" | "force"; + refreshProvider?: AgentChatModelCatalogRefreshProvider; + cursorSource?: "sdk" | "cli"; + }, ): AgentChatModelCatalog { if (args.mode === "cached" && sharedRuntimeCatalog) { for (const provider of REFRESH_PROVIDERS) { @@ -83,12 +126,13 @@ export function rememberRuntimeCatalog( } sharedRuntimeCatalog = catalog; + const cursorFlavor = args.refreshProvider === "cursor" ? args.cursorSource : undefined; if ( args.refreshProvider && (args.mode === "force" || catalog.stale !== true) - && shouldMarkRefreshProviderFresh(catalog, args.refreshProvider) + && shouldMarkRefreshProviderFresh(catalog, args.refreshProvider, cursorFlavor) ) { - markRuntimeCatalogProviderFresh(args.refreshProvider); + markRuntimeCatalogProviderFresh(args.refreshProvider, Date.now(), cursorFlavor); return catalog; } if (args.mode === "cached" && catalog.stale !== true) { diff --git a/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts b/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts index 4104f7689..b2697574c 100644 --- a/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts +++ b/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts @@ -47,8 +47,9 @@ function modelsCacheKey( projectRoot: string | null | undefined, provider: AgentChatProvider, activateRuntime: boolean, + cursorSource?: "sdk" | "cli" | "all", ): string { - return `${normalizeProjectRoot(projectRoot)}::${provider}::${activateRuntime ? "active" : "passive"}`; + return `${normalizeProjectRoot(projectRoot)}::${provider}::${activateRuntime ? "active" : "passive"}::${cursorSource ?? "all"}`; } /** @@ -141,11 +142,12 @@ export async function getAgentChatModelsCached(args: { projectRoot: string | null | undefined; provider: AgentChatProvider; activateRuntime?: boolean; + cursorSource?: "sdk" | "cli" | "all"; force?: boolean; ttlMs?: number; }): Promise { const activateRuntime = args.activateRuntime === true; - const key = modelsCacheKey(args.projectRoot, args.provider, activateRuntime); + const key = modelsCacheKey(args.projectRoot, args.provider, activateRuntime, args.cursorSource); const ttlMs = args.ttlMs ?? DEFAULT_MODELS_TTL_MS; const now = Date.now(); const existing = providerModelsCache.get(key); @@ -161,6 +163,7 @@ export async function getAgentChatModelsCached(args: { request = window.ade.agentChat.models({ provider: args.provider, ...(activateRuntime ? { activateRuntime: true } : {}), + ...(args.cursorSource ? { cursorSource: args.cursorSource } : {}), }).then((models) => { const current = providerModelsCache.get(key); if (current?.inFlight === request) { diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 651e83734..64cce0868 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -1295,9 +1295,19 @@ export type AgentChatModelCatalogRefreshProvider = export type AgentChatModelCatalogMode = "cached" | "refresh-stale" | "force"; +/** + * Which cursor discovery source the requesting surface needs synchronously. + * Chat surfaces run models through the Cursor SDK ("sdk"); Work-tab CLI lane + * drafts run the cursor-agent CLI ("cli"). "all" (default) probes both, which + * makes the refresh wait on the slower CLI spawn — surfaces that know their + * flavor should pass it so the other source revalidates in the background. + */ +export type AgentChatCursorModelSource = "sdk" | "cli" | "all"; + export type AgentChatModelCatalogArgs = { mode?: AgentChatModelCatalogMode; refreshProvider?: AgentChatModelCatalogRefreshProvider; + cursorSource?: AgentChatCursorModelSource; }; export type AgentChatCreateArgs = { @@ -1584,6 +1594,7 @@ export type AgentChatRespondToInputArgs = { export type AgentChatModelsArgs = { provider: AgentChatProvider; activateRuntime?: boolean; + cursorSource?: AgentChatCursorModelSource; }; export type AgentChatDisposeArgs = { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 9c156025f..94b463ab1 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -4321,9 +4321,21 @@ final class SyncService: ObservableObject { let task = Task { @MainActor [weak self] in guard let self else { throw CancellationError() } + // Cursor/droid model lists are discovered dynamically on the host; + // activate the runtime so a fresh key surfaces models on first fetch + // instead of returning an empty passive cache (mirrors the TUI). Mobile + // chats run cursor models through the SDK, so only that source is + // probed synchronously — the CLI flavor revalidates in the background. + var args: [String: Any] = ["provider": normalizedProvider] + if normalizedProvider == "cursor" || normalizedProvider == "droid" { + args["activateRuntime"] = true + } + if normalizedProvider == "cursor" { + args["cursorSource"] = "sdk" + } let response = try await self.sendCommand( action: "chat.models", - args: ["provider": normalizedProvider], + args: args, disconnectOnTimeout: false, timeoutMessage: "Model list is still loading from the machine.", timeoutNanoseconds: SyncRequestTimeout.modelCatalogTimeoutNanoseconds diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index fcfd1809a..2df445947 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -34,7 +34,7 @@ machinery layered on top. | `apps/desktop/src/main/services/chat/cursorSdkPolicy.ts` | Maps ADE permission modes onto Cursor SDK chat mode + approval policy + sandbox mode (`ade` / `cursor-native` / `off`); decides which tool calls auto-approve and which require a user prompt. | | `apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.ts` | Builds the system prompt the Cursor worker injects (lane context, ADE CLI guidance, persona overlays). | | `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. | -| `apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts` | Probes the live `@cursor/sdk` and `cursor-agent` CLI model lists, merges their descriptors, and records `cursorAvailability` so chat sessions see SDK-capable models while Work CLI launches can include CLI-only models. Both JSON and text probes preserve aliases, descriptions, `parameters[]`, and `variants[]`; `*-fast` CLI rows are folded into their base model as `aliases` + `serviceTiers: ["fast"]` so the picker shows one model with a Fast toggle instead of duplicate "Fast" rows. Parameter and variant metadata is classified into `reasoningTiers` (`none`/`dynamic`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`/`thinking`) and `serviceTiers` (`fast`). `resolveCursorSdkModelSelectionParams` rebuilds the matching `CursorSdkModelParameterValue[]` so the SDK boot can target the right variant. The previous minimal `auto` / `composer-2` fallback list has been removed. | +| `apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts` | Probes the live `@cursor/sdk` and `cursor-agent` CLI model lists, merges their descriptors, and records `cursorAvailability` so chat sessions see SDK-capable models while Work CLI launches can include CLI-only models. Both JSON and text probes preserve aliases, descriptions, `parameters[]`, and `variants[]`; `*-fast` CLI rows are folded into their base model as `aliases` + `serviceTiers: ["fast"]` so the picker shows one model with a Fast toggle instead of duplicate "Fast" rows. Parameter and variant metadata is classified into `reasoningTiers` (`none`/`dynamic`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`/`thinking`) and `serviceTiers` (`fast`). `resolveCursorSdkModelSelectionParams` rebuilds the matching `CursorSdkModelParameterValue[]` so the SDK boot can target the right variant. The previous minimal `auto` / `composer-2` fallback list has been removed. **Cache resilience:** both the SDK and CLI caches are stale-while-revalidate — last-known-good rows are served well past the 120s freshness window (up to ~6h) and a background warm (at most one attempt per freshness window, so a broken CLI/SDK is not re-spawned on every passive read) refreshes them, so verified-provider models never blink out on passive status reads (`availableModelIds`, mobile, TUI). `markCursorModelCachesStale` ages the caches without dropping rows — generic readiness invalidation (forced status refresh, verifying any provider's key) calls it, while only a cursor key change does a full `clearCursorCliModelsCache`. Auth/SDK-resolution failures drop the SDK cache (a dead key/unusable module must not resurface phantom models); transient failures keep serving last-known-good. When the signed-in CLI reports "No models available" its cache is dropped and a provider runtime failure is surfaced (the stored login lost model access; re-auth via `cursor-agent logout`). | | `apps/desktop/src/main/services/chat/droidSdkPool.ts` | Droid SDK adapter. Forks `droidSdkWorker.cjs` per session, exposes `acquireDroidSdkConnection` / `releaseDroidSdkConnection`, and proxies prompt sends, settings updates, permission decisions, ask-user responses, and cancellation through the worker. Resolves the Droid SDK CLI executable via `resolveDroidExecutable` (PATH + bundle + configured install paths). | | `apps/desktop/src/main/services/chat/droidSdkWorker.ts` | Node worker that hosts `@factory/droid-sdk`. Streams SDK events back to the main process and forwards permission / ask-user prompts back through the JSON-line protocol. | | `apps/desktop/src/main/services/chat/droidSdkProtocol.ts` | Worker IPC types: `DroidSdkSessionSettings` (autonomy level, interaction mode, reasoning effort), `DroidSdkReasoningEffort`, `DroidSdkPermissionRequest`/`Decision`, `DroidSdkAskUserRequest`/`Response`, `DroidSdkReady` (handshake with `availableModels`), and `DroidSdkSendPrompt`. | diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 2afb1eb35..d071940fb 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -18,7 +18,7 @@ where the machinery lives. | `apps/desktop/src/main/services/ai/codexExecutable.ts` / `droidExecutable.ts` | CLI resolution for runtimes that still need an external binary (looks on PATH, in the app bundle, then in configured install paths where supported). Claude uses the bundled Claude Agent SDK binary; Cursor and Droid run through embedded SDKs (`@cursor/sdk`, `@factory/droid-sdk`). | | `apps/desktop/src/main/services/ai/tools/systemPrompt.ts` | Adjusts the system prompt per mode (`chat`, `coding`, `planning`) and permission mode. | | `apps/desktop/src/main/services/chat/droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts` | Droid SDK adapter. `droidSdkPool` forks `droidSdkWorker.cjs` (one per session), brokers prompt sends, permission requests, ask-user prompts, and settings updates via the JSON-line protocol in `droidSdkProtocol`. `droidSdkEventMapper` translates Droid SDK events into the canonical `AgentChatEventEnvelope` shape; the per-session mapper state (`createDroidSdkEventMapperState`) tracks streaming text/thinking item ids, in-flight tool-use names, and the latest usage breakdown. | -| `apps/desktop/src/main/services/chat/droidModelsDiscovery.ts` | Droid model discovery: probes the live SDK via `createSession({ execPath })` to read `initResult.availableModels`, normalizes `supportedReasoningEfforts` into `reasoningTiers`, and emits `droid/` descriptors via `createDynamicDroidCliModelDescriptor`. Droid fast choices are distinct model IDs, not ADE `serviceTiers`; custom (`~/.factory/config.json`) models are merged in. The legacy `DROID_DEFAULT_MODEL_IDS` constant has been removed — the SDK is the only source. | +| `apps/desktop/src/main/services/chat/droidModelsDiscovery.ts` | Droid model discovery: probes the live SDK via `createSession({ execPath })` to read `initResult.availableModels`, normalizes `supportedReasoningEfforts` into `reasoningTiers`, and emits `droid/` descriptors via `createDynamicDroidCliModelDescriptor`. Droid fast choices are distinct model IDs, not ADE `serviceTiers`; custom (`~/.factory/config.json`) models are merged in. The legacy `DROID_DEFAULT_MODEL_IDS` constant has been removed — the SDK is the only source. Like Cursor, the cache is stale-while-revalidate: `markDroidModelCachesStale` ages it without dropping last-known-good rows, which are served past the 120s window (up to ~6h) while one background warm per freshness window refreshes them, so an unauthenticated/mid-reauth droid isn't handed a session per passive read. | ## Supported providers @@ -384,6 +384,18 @@ through `workerAgentService`. in `localModelDiscovery.ts` can add and remove descriptors. Callers that cache the registry must subscribe to the discovery emitter or re-read on each use. +- **Cursor discovery is surface-scoped (`cursorSource`).** Chat surfaces + run Cursor models through the SDK (~300ms); Work-tab CLI lane drafts run + the `cursor-agent` CLI (a process spawn that can take seconds). + `loadAvailableModels` / `getModelCatalog` accept a `cursorSource` + (`"sdk" | "cli" | "all"`, default `"all"`) and probe only the requested + source synchronously — the other source serves last-known-good rows and + revalidates in the background, so a chat picker refresh never waits on a + CLI spawn. The TUI (`adeApi.ts`), mobile (`SyncService.swift`), and the + desktop ModelPicker all pass `cursorSource: "sdk"`. Catalog staleness is + flavor-aware: an SDK-only refresh does **not** satisfy a CLI-surface + staleness check (it gates on `cursorAvailability[source]`, not just + `modelCount > 0`), mirrored on the renderer in `runtimeCatalogCache.ts`. - **Handoff requires context contract.** `handoffSession` calls the summarizer with the current transcript plus the context contract from `contextContract.ts`. If the contract can't be resolved (e.g. diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index b0478c548..aa5665852 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -156,11 +156,15 @@ before. - `restart`, `updateSession`, `archive`, `unarchive`, `delete`, `models`, `modelCatalog` -`chat.modelCatalog` accepts `{ mode?, refreshProvider? }` where `mode` -is `"cached" | "refresh-stale" | "force"` (default `"cached"`) and -`refreshProvider` is `"opencode" | "cursor" | "droid" | "lmstudio" | -"ollama"`. The brain returns the full provider-grouped catalog used by -the desktop and TUI ModelPickers and the iOS Work model sheet; only +`chat.modelCatalog` accepts `{ mode?, refreshProvider?, cursorSource? }` +where `mode` is `"cached" | "refresh-stale" | "force"` (default +`"cached"`) and `refreshProvider` is `"opencode" | "cursor" | "droid" | +"lmstudio" | "ollama"`. `cursorSource` (`"sdk" | "cli" | "all"`, default +`"all"`) scopes which Cursor discovery source the host probes +synchronously — chat-style surfaces pass `"sdk"` so the refresh stays off +the slower `cursor-agent` CLI spawn while the CLI flavor revalidates in +the background. The brain returns the full provider-grouped catalog used +by the desktop and TUI ModelPickers and the iOS Work model sheet; only explicit `force` / `refresh-stale` calls trigger a runtime probe. `chat.dispatchSteer` (Claude SDK only) takes @@ -460,12 +464,16 @@ the brain when the Linear workspace is connected. Controllers use these to render the workspace brand on Linear-related surfaces without fetching them separately. -`parseChatModelsArgs` accepts `{ provider, activateRuntime? }`. When -`chat.create` is missing an explicit model, `resolveChatCreateArgs` +`parseChatModelsArgs` accepts `{ provider, activateRuntime?, cursorSource? }` +(`cursorSource` is `"sdk" | "cli" | "all"`, mirroring `chat.modelCatalog`). +When `chat.create` is missing an explicit model, `resolveChatCreateArgs` forwards `activateRuntime: true` only for the `opencode` provider so the brain actually launches the OpenCode probe server before resolving a default model. All other providers use passive (cache-only) resolution; -see the chat README for the passive/active contract. +see the chat README for the passive/active contract. The iOS companion's +`chat.models` request sets `activateRuntime: true` for cursor/droid and +`cursorSource: "sdk"` for cursor so a fresh key surfaces SDK models on the +first fetch instead of returning an empty passive cache. ## Gotchas