From 1447cfc319096cc20aba007b95c0e0885ee45100 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:16:54 -0400 Subject: [PATCH 1/9] Cursor models survive verification: last-known-good cache + passive warm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verified Cursor API key produced zero models on every surface because the dynamic model caches were write-once-wipe-often: - invalidateProviderReadinessCaches() (which runs on storeApiKey, on every key verification, and on every forced ai.getStatus) blanked the cursor/ droid model rows the verification probe had just warmed. - The positive cache only lived 120s, and the passive status path used "cached-only" discovery, so availableModelIds lost all cursor ids two minutes after any active probe — desktop gating, iOS, and the TUI then saw an empty provider. Fixes: - cursorModelsDiscovery: serve last-known-good rows for up to 6h with stale-while-revalidate (background warm past the 120s freshness window). Auth failures and SDK resolution failures still blank the cache (dead key / unusable runtime must not advertise phantom models); transient failures no longer do. Generic readiness invalidation now ages caches via markCursorModelCachesStale() instead of dropping them; a cursor key change still does a full clear. - aiIntegrationService: passive cursor discovery is "cached-or-fallback" so a cold cache warms in the background instead of never populating. - droidModelsDiscovery: same stale-while-revalidate + stale-mark treatment. - listCursorModelsFromCli: detect the CLI's "No models available for this account" answer (a stale CLI login session shadows CURSOR_API_KEY) and surface a provider blocker with the cursor-agent logout remediation. - iOS SyncService: chat.models for cursor/droid passes activateRuntime so a fresh key surfaces models on first fetch (mirrors the TUI). Verified on a lane-built isolated runtime: store key -> verify -> 40 models passively, still present >5min later and across a forced status refresh, and a composer-2.5 ADE chat round-trip ("pong") through the Cursor SDK worker. Co-Authored-By: Claude Fable 5 --- .../services/ai/aiIntegrationService.test.ts | 7 +- .../main/services/ai/aiIntegrationService.ts | 19 +++- .../services/chat/cursorModelsDiscovery.ts | 93 +++++++++++++++++-- .../services/chat/droidModelsDiscovery.ts | 38 +++++++- apps/ios/ADE/Services/SyncService.swift | 9 +- 5 files changed, 148 insertions(+), 18 deletions(-) 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..81c985981 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,13 @@ 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. + // Key changes do a full clear at the storeApiKey/deleteApiKey call sites. + markCursorModelCachesStale(); + markDroidModelCachesStale(); clearOpenCodeBinaryCache(); clearOpenCodeInventoryCache(); replaceDynamicOpenCodeModelDescriptors([]); @@ -1902,10 +1911,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/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index f2659b972..b54fcae5b 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -48,11 +48,17 @@ export type CursorSdkModelDiscoveryResult = { }; let cached: { at: number; models: CursorCliModelRow[] } | null = null; +let cliWarmInFlight: Promise | null = null; 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 +297,28 @@ function foldCursorCliVariantRows(rows: CursorCliModelRow[]): CursorCliModelRow[ export function clearCursorCliModelsCache(): void { cached = null; + cliWarmInFlight = null; 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; +} + function hashKeyForCache(key: string | null | undefined): string { const text = String(key ?? ""); return createHash("sha256").update(text).digest("hex").slice(0, 16); @@ -548,6 +570,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,10 +658,13 @@ 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; @@ -684,6 +714,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); } @@ -803,14 +836,26 @@ 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 { + if (cliWarmInFlight) return; + 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 +1113,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 +1189,13 @@ 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 ?? []; + if (!rows.length && result?.failureKind !== "auth") { + // Cache lookup covers the cached modes and lets a transiently-failed + // probe fall back to last-known-good rows. An auth failure means the key + // itself is dead, so stale rows for it must not resurface. + rows = getCachedCursorSdkModels(apiKey) ?? []; + } if (!rows.length && options?.mode === "cached-or-fallback") { warmCursorModelsFromSdk(apiKey); } diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index a678cec86..697364669 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -22,7 +22,11 @@ type DroidCliModelDiscoveryMode = "probe" | "cached-or-fallback"; let cached: { at: number; models: DroidExecHelpModelRow[] } | null = null; let inflight: Promise | null = null; +let warmInFlight = false; 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/); @@ -266,11 +270,38 @@ export function clearDroidCliModelsCache(): void { inflight = null; } -function getCachedDroidModels(): DroidExecHelpModelRow[] | null { +/** + * Age the positive model cache so the next consumer revalidates, without + * dropping last-known-good rows. Generic provider readiness invalidation + * must not blank the droid model list — only a credential change does that + * ({@link clearDroidCliModelsCache}). + */ +export function markDroidModelCachesStale(): void { + if (cached) cached = { ...cached, at: Math.min(cached.at, Date.now() - TTL_MS) }; +} + +function warmDroidModels(droidPath: string): void { + if (warmInFlight) return; + warmInFlight = true; + void listDroidModelsFromSdk(droidPath) + .catch(() => 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 +342,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/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 9c156025f..e10fa1586 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -4321,9 +4321,16 @@ 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). + var args: [String: Any] = ["provider": normalizedProvider] + if normalizedProvider == "cursor" || normalizedProvider == "droid" { + args["activateRuntime"] = true + } 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 From 75edc4282eb7bbfd0f6ddc9a2c7555271f7ab2b8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:21:43 -0400 Subject: [PATCH 2/9] Audit fixes: backoff for background model-cache warms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-while-revalidate change could spawn a discovery probe on every passive call when there was nothing to serve: an unauthenticated droid got an SDK session per status read, a broken/missing cursor CLI got re-spawned (4 probes, 12s timeouts) per call, and a timed-out cursor SDK fetch retried on every passive consumer because the warm gate only counted auth/unavailable failures. - warmCursorModelsFromCli / warmDroidModels: at most one background attempt per freshness window (reset on key change, full clear, or stale-mark so a forced refresh can retry immediately). - warmDroidModels also skips when a fresh cached result exists, even an empty one — droid legitimately caches empty rows for unauthed CLIs. - hasRecentCursorSdkFailure now defers warms after timeout failures too; it only gates background warms, active probes always run. Co-Authored-By: Claude Fable 5 --- .../main/services/chat/cursorModelsDiscovery.ts | 15 ++++++++++++--- .../main/services/chat/droidModelsDiscovery.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index b54fcae5b..edd332085 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -49,6 +49,7 @@ 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; @@ -298,6 +299,7 @@ function foldCursorCliVariantRows(rows: CursorCliModelRow[]): CursorCliModelRow[ export function clearCursorCliModelsCache(): void { cached = null; cliWarmInFlight = null; + cliLastWarmAttemptAt = 0; sdkCached = null; sdkWarmInFlight = null; sdkLastFailure = null; @@ -317,6 +319,7 @@ export function markCursorModelCachesStale(): void { 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 { @@ -671,11 +674,13 @@ function getCachedCursorSdkModels(apiKey?: string | null): CursorCliModelRow[] | } 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, ); } @@ -848,7 +853,11 @@ function getCachedCursorModels(agentPathForRevalidate?: string | null): CursorCl } function warmCursorModelsFromCli(agentPath: string): void { - if (cliWarmInFlight) return; + 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(() => { diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index 697364669..c98c50f23 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -23,6 +23,7 @@ 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. @@ -268,6 +269,7 @@ async function listDroidModelsFromSdk(droidPath: string): Promise undefined) From 4e89bb0a7f517bf22553ae59a036ff1ca1825f5f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:02:19 -0400 Subject: [PATCH 3/9] Surface-scoped cursor model discovery (sdk vs cli) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cursor refresh used to probe BOTH discovery sources and wait on the slower one: the SDK list returns in ~300ms but the cursor-agent CLI is a process spawn with multi-second timeouts. Chat surfaces only run models through the SDK, and Work-tab CLI lane drafts only through the CLI, so each was paying for a probe it didn't need — worst on first discovery right after key entry, when nothing is cached and the picker waits on the CLI before any cursor rows appear. - New optional cursorSource ("sdk" | "cli" | "all") on AgentChatModelsArgs and AgentChatModelCatalogArgs. The cursor branch of loadAvailableModels probes only the requested source synchronously; the other source serves last-known-good rows and revalidates via the background warm paths. - Catalog freshness is flavor-aware on both sides (brain modelCatalog staleness + renderer runtimeCatalogCache): an SDK-only refresh cannot satisfy a CLI-surface staleness check, so the work picker still forces its own CLI probe when cli-capable rows are missing. - Desktop ModelPicker derives the source from cursorAvailabilityMode (chat→sdk, cli→cli, all→both); TUI and iOS chat pickers pass "sdk". Default stays "all", so older clients/brains keep today's behavior in both mix directions. - Request dedupe keys (models, catalog, renderer caches) include the source. Verified on an isolated lane-built runtime: cold sdk-flavored active probe returns 19 SDK rows without waiting on the CLI; the background CLI warm fills the merged 40-row view seconds later; legacy no-source calls still probe both. Co-Authored-By: Claude Fable 5 --- .../services/sync/syncRemoteCommandService.ts | 15 ++++++- apps/ade-cli/src/tuiClient/adeApi.ts | 3 ++ .../main/services/chat/agentChatService.ts | 43 +++++++++++++++---- .../shared/ModelPicker/ModelPicker.test.tsx | 4 +- .../shared/ModelPicker/ModelPicker.tsx | 27 +++++++++--- .../shared/ModelPicker/runtimeCatalogCache.ts | 15 ++++++- .../src/renderer/lib/aiDiscoveryCache.ts | 7 ++- apps/desktop/src/shared/types/chat.ts | 11 +++++ apps/ios/ADE/Services/SyncService.swift | 7 ++- 9 files changed, 110 insertions(+), 22 deletions(-) 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/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/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1f0213e4d..fef4711a0 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, @@ -23303,18 +23304,29 @@ export function createAgentChatService(args: { 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) { - if (refreshProvider === "cursor" && !modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider)) return true; + if (refreshProvider === "cursor" && !modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider, cursorSource)) return true; const refreshedAt = modelCatalogProviderRefreshedAt.get(refreshProvider); return !refreshedAt || Date.now() - refreshedAt > modelCatalogRefreshTtlMs(refreshProvider); } @@ -23368,6 +23380,7 @@ export function createAgentChatService(args: { const loadAvailableModels = async (args: { provider: AgentChatProvider; activateRuntime?: boolean; + cursorSource?: AgentChatCursorModelSource; }): Promise => { const provider = args.provider; if (provider === "codex") { @@ -23387,15 +23400,23 @@ 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([]), ]); @@ -23571,17 +23592,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 +23618,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 +23647,9 @@ export function createAgentChatService(args: { activateRuntime: (provider === "cursor" && shouldRefreshProvider("cursor")) || (provider === "droid" && shouldRefreshProvider("droid")), + ...(provider === "cursor" && catalogArgs?.cursorSource + ? { cursorSource: catalogArgs.cursorSource } + : {}), }), }; } catch { @@ -23833,11 +23859,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/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 44cd5d75e..2ad2e93bb 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" }); }); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index b3548a9e0..063f753ac 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,7 +125,10 @@ export const ModelPicker = memo(function ModelPicker({ const request = (async () => { try { - const next = await bridge(args); + const next = await bridge({ + ...args, + ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), + }); const visible = rememberRuntimeCatalog(next, args); setRuntimeCatalog(visible); return visible; @@ -128,7 +142,7 @@ export const ModelPicker = memo(function ModelPicker({ clearRuntimeCatalogRequest(requestKey, request); }); return await request; - }, []); + }, [cursorSource]); useEffect(() => { if (!open) return; @@ -150,10 +164,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 +182,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.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index 21340e732..40fd32e45 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -33,12 +33,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); }); } @@ -58,9 +66,12 @@ function markRuntimeCatalogProviderFresh( sharedRuntimeCatalogProviderRefreshedAt.set(provider, refreshedAt); } -export function runtimeCatalogProviderIsFresh(provider: AgentChatModelCatalogRefreshProvider): boolean { +export function runtimeCatalogProviderIsFresh( + provider: AgentChatModelCatalogRefreshProvider, + cursorFlavor?: "sdk" | "cli", +): boolean { const refreshedAt = sharedRuntimeCatalogProviderRefreshedAt.get(provider); - if (provider === "cursor" && (!sharedRuntimeCatalog || !catalogContainsRefreshProvider(sharedRuntimeCatalog, provider))) { + if (provider === "cursor" && (!sharedRuntimeCatalog || !catalogContainsRefreshProvider(sharedRuntimeCatalog, provider, cursorFlavor))) { return false; } return Boolean(refreshedAt && Date.now() - refreshedAt <= runtimeCatalogRefreshTtlMs(provider)); 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 e10fa1586..94b463ab1 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -4323,11 +4323,16 @@ final class SyncService: ObservableObject { 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). + // 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: args, From c12fd5d8c9c2186c1a52211ef6807edac1da595c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:18:28 -0400 Subject: [PATCH 4/9] ship: tests, docs, and TUI cursorSource parity for the cursor model fix automate + finalize pass over the cursor/droid model-discovery lane: - Tests: stale-while-revalidate + transient-vs-auth cache behavior and the CLI "no models available" detection (cursorModelsDiscovery); stale-mark + single-warm backoff (droidModelsDiscovery); new flavor-aware staleness coverage (runtimeCatalogCache). No suite bloat; touched files were clean. - TUI parity: app.tsx model-catalog refresh now passes cursorSource:"sdk" for cursor (both refresh-stale and the force follow-up), matching getAvailableModels / desktop ModelPicker / iOS so the TUI catalog probe stays off the host cursor-agent CLI spawn. Updated the stale adeApi test. - Docs: chat README + agent-routing + remote-commands reflect the cache resilience and the cursorSource RPC contract. - Tightened a misleading comment and a test helper type (non-optional cursorAvailability). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tuiClient/__tests__/adeApi.test.ts | 4 +- apps/ade-cli/src/tuiClient/app.tsx | 5 ++ .../main/services/ai/aiIntegrationService.ts | 3 +- .../chat/cursorModelsDiscovery.test.ts | 46 ++++++++++ .../chat/droidModelsDiscovery.test.ts | 42 +++++++++ .../ModelPicker/runtimeCatalogCache.test.ts | 88 +++++++++++++++++++ docs/features/chat/README.md | 2 +- docs/features/chat/agent-routing.md | 14 ++- .../sync-and-multi-device/remote-commands.md | 24 +++-- 9 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts 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/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.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 81c985981..2a7ef951e 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1634,7 +1634,8 @@ export function createAiIntegrationService(args: { // 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. - // Key changes do a full clear at the storeApiKey/deleteApiKey call sites. + // 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(); diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts index 30df9becc..6b3102309 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, @@ -661,4 +662,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/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/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts new file mode 100644 index 000000000..5e4046f65 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts @@ -0,0 +1,88 @@ +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 check (modelCount > 0) stays fresh — the generic + // path is unchanged for non-flavored callers. + expect(runtimeCatalogProviderIsFresh("cursor")).toBe(true); + }); + + 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); + }); + + 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/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 From 7994f240a2a5c0e6e48928864038dd55a6ebdedb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:37:03 -0400 Subject: [PATCH 5/9] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20address?= =?UTF-8?q?=20Greptile=20P2s=20(droid=20warm=20flag,=20explicit=20cache=20?= =?UTF-8?q?fallback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clearDroidCliModelsCache now resets warmInFlight, matching clearCursorCliModelsCache's cliWarmInFlight reset — a clear during an in-flight warm no longer blocks the next warm until the old promise settles. - discoverCursorSdkModelDescriptors makes the cached-mode branch explicit (result == null || failureKind !== "auth"); behavior-preserving, clarifies that a null result is the cached path, not an auth check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/services/chat/cursorModelsDiscovery.ts | 8 ++++---- .../src/main/services/chat/droidModelsDiscovery.ts | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index edd332085..3f5dddaf3 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -1199,10 +1199,10 @@ export async function discoverCursorSdkModelDescriptors( ? await probeCursorSdkModelDiscovery(apiKey, { timeoutMs: options?.timeoutMs }) : null; let rows = result?.rows ?? []; - if (!rows.length && result?.failureKind !== "auth") { - // Cache lookup covers the cached modes and lets a transiently-failed - // probe fall back to last-known-good rows. An auth failure means the key - // itself is dead, so stale rows for it must not resurface. + if (!rows.length && (result == null || result.failureKind !== "auth")) { + // Cache lookup covers the cached modes (result == null) and lets a + // transiently-failed probe fall back to last-known-good rows. An auth + // failure means the key itself is dead, so stale rows must not resurface. rows = getCachedCursorSdkModels(apiKey) ?? []; } if (!rows.length && options?.mode === "cached-or-fallback") { diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index c98c50f23..c22d272f9 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -269,6 +269,7 @@ async function listDroidModelsFromSdk(droidPath: string): Promise Date: Wed, 10 Jun 2026 13:53:59 -0400 Subject: [PATCH 6/9] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20authori?= =?UTF-8?q?tative=20empty=20Cursor=20probe=20no=20longer=20serves=20stale?= =?UTF-8?q?=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: an active (mode:"probe") SDK fetch that SUCCEEDS with an empty model list (failureKind == null) was falling back to the 6h last-known-good cache, so a forced refresh kept advertising models the provider had just reported as gone. Limit the cache fallback to the cached modes (result == null) and genuine transient failures; a successful-but-empty probe and an auth failure both reflect their result verbatim. Adds a regression test. Supersedes the cosmetic iter-1 readability tweak on this line. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../chat/cursorModelsDiscovery.test.ts | 18 ++++++++++++++++++ .../services/chat/cursorModelsDiscovery.ts | 12 ++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts index 6b3102309..8db233a54 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts @@ -610,6 +610,24 @@ 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([]); + }); + 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({ diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index 3f5dddaf3..bbf96afe6 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -1199,10 +1199,14 @@ export async function discoverCursorSdkModelDescriptors( ? await probeCursorSdkModelDiscovery(apiKey, { timeoutMs: options?.timeoutMs }) : null; let rows = result?.rows ?? []; - if (!rows.length && (result == null || result.failureKind !== "auth")) { - // Cache lookup covers the cached modes (result == null) and lets a - // transiently-failed probe fall back to last-known-good rows. An auth - // failure means the key itself is dead, so stale rows must not resurface. + // 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") { From 520971121dc0c19abada5455ace2dcc727b59039 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:09:36 -0400 Subject: [PATCH 7/9] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20drop=20?= =?UTF-8?q?stale=20SDK=20cache=20on=20an=20authoritative=20empty=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 follow-up: iter-2 stopped the active probe from serving stale rows on an authoritative empty result, but left the sdkCached entry in place — so the next passive cached-or-fallback read (status/mobile/TUI) resurrected the old models for up to the 6h positive TTL. fetchCursorModelsFromSdk now clears the matching SDK cache when a fetch returns empty without throwing (both SDK and official API reported zero models; timeouts/auth/transient throw and never reach this path). Extends the regression test to assert the passive read is also empty. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/services/chat/cursorModelsDiscovery.test.ts | 6 ++++++ .../src/main/services/chat/cursorModelsDiscovery.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts index 8db233a54..2be597913 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts @@ -626,6 +626,12 @@ describe("parseCursorCliModelsStdout", () => { 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 () => { diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index bbf96afe6..c417096a3 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -742,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; From 53a372d4da7a97a08605a4f2b097553a26e91f7c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:25:44 -0400 Subject: [PATCH 8/9] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20scope?= =?UTF-8?q?=20returned=20Cursor=20models=20to=20the=20requested=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: cursorSource controlled which source was probed but not which rows were returned, so a "sdk" surface (TUI/mobile chat) still received CLI-only cached rows in the merged list — and surfaces that don't filter client-side could offer a model that throws in assertCursorChatModelCanUseSdk on select (symmetric for "cli"). getAvailableModels now filters the merged descriptors to the requested source; the skipped source still annotates availability on dual-capable rows, only rows exclusive to the skipped source are dropped. "all" returns the union (unchanged). Extends the CLI-models test to assert an sdk-scoped request drops CLI-only rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/services/chat/agentChatService.test.ts | 11 +++++++++++ .../src/main/services/chat/agentChatService.ts | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) 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 fef4711a0..ddcaf21cf 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -23420,7 +23420,20 @@ export function createAgentChatService(args: { }).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, From 1c0d000ad7db5dad69c4c382832a5bb07500ca85 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:04:15 -0400 Subject: [PATCH 9/9] =?UTF-8?q?ship:=20iteration=205=20=E2=80=94=20track?= =?UTF-8?q?=20cursor=20catalog=20freshness=20per=20discovery=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: catalog freshness was keyed globally per provider, so an SDK-scoped cursor refresh marked "cursor" fresh for the whole TTL. Because dual-capable rows keep cursorAvailability.cli=true, a later CLI-scoped request saw both "catalog contains cli rows" and "globally fresh" and skipped its force CLI probe, missing CLI-only model changes / CLI auth blockers for up to 30 min (symmetric for the SDK side after a CLI refresh). Both the brain (agentChatService model-catalog staleness) and the renderer's client-side short-circuit (runtimeCatalogCache) now track cursor freshness per source: a scoped refresh marks only the probed source; an unscoped/"all" refresh marks both; an "all" staleness check requires both fresh. shouldMark is source-aware so a source isn't marked fresh without rows it can run. Updated two tests that encoded the old global-timestamp behavior and added coverage for the dual-capable-rows + sdk-scoped-refresh case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/services/chat/agentChatService.ts | 40 ++++++++++++++-- .../shared/ModelPicker/ModelPicker.test.tsx | 19 +++++++- .../shared/ModelPicker/ModelPicker.tsx | 5 +- .../ModelPicker/runtimeCatalogCache.test.ts | 22 +++++++-- .../shared/ModelPicker/runtimeCatalogCache.ts | 47 ++++++++++++++++--- 5 files changed, 116 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ddcaf21cf..e5e5a650e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -23288,16 +23288,36 @@ 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); + } } }; @@ -23325,8 +23345,17 @@ export function createAgentChatService(args: { 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, cursorSource)) return true; const refreshedAt = modelCatalogProviderRefreshedAt.get(refreshProvider); return !refreshedAt || Date.now() - refreshedAt > modelCatalogRefreshTtlMs(refreshProvider); } @@ -23345,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 => { @@ -23841,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; }; 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 2ad2e93bb..d12c07f09 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -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 063f753ac..b8dd1a1fd 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -129,7 +129,10 @@ export const ModelPicker = memo(function ModelPicker({ ...args, ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), }); - const visible = rememberRuntimeCatalog(next, args); + const visible = rememberRuntimeCatalog(next, { + ...args, + ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), + }); setRuntimeCatalog(visible); return visible; } catch { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts index 5e4046f65..174788b57 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.test.ts @@ -65,9 +65,24 @@ describe("runtimeCatalogCache flavor-aware cursor freshness", () => { // ...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 check (modelCount > 0) stays fresh — the generic - // path is unchanged for non-flavored callers. - expect(runtimeCatalogProviderIsFresh("cursor")).toBe(true); + // 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", () => { @@ -78,6 +93,7 @@ describe("runtimeCatalogCache flavor-aware cursor freshness", () => { 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", () => { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index 40fd32e45..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(); } @@ -54,15 +59,30 @@ 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); } @@ -70,16 +90,28 @@ export function runtimeCatalogProviderIsFresh( provider: AgentChatModelCatalogRefreshProvider, cursorFlavor?: "sdk" | "cli", ): boolean { - const refreshedAt = sharedRuntimeCatalogProviderRefreshedAt.get(provider); - if (provider === "cursor" && (!sharedRuntimeCatalog || !catalogContainsRefreshProvider(sharedRuntimeCatalog, provider, cursorFlavor))) { - return false; + 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) { @@ -94,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) {