Skip to content

Commit b28735f

Browse files
arul28claude
andauthored
Cursor models survive key verification (last-known-good cache + surface-scoped discovery) (#550)
* Cursor models survive verification: last-known-good cache + passive warm 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 <noreply@anthropic.com> * Audit fixes: backoff for background model-cache warms 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 <noreply@anthropic.com> * Surface-scoped cursor model discovery (sdk vs cli) 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 <noreply@anthropic.com> * 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) <noreply@anthropic.com> * ship: iteration 1 — address Greptile P2s (droid warm flag, explicit cache fallback) - 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) <noreply@anthropic.com> * ship: iteration 2 — authoritative empty Cursor probe no longer serves stale rows 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) <noreply@anthropic.com> * ship: iteration 3 — drop stale SDK cache on an authoritative empty probe 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) <noreply@anthropic.com> * ship: iteration 4 — scope returned Cursor models to the requested source 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) <noreply@anthropic.com> * ship: iteration 5 — track cursor catalog freshness per discovery source 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e3a6a7e commit b28735f

22 files changed

Lines changed: 664 additions & 65 deletions

apps/ade-cli/src/services/sync/syncRemoteCommandService.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1195,16 +1195,28 @@ function parseConflictLaneArgs(value: Record<string, unknown>, action: string):
11951195
};
11961196
}
11971197

1198-
function parseChatModelsArgs(value: Record<string, unknown>): { provider: AgentChatProvider; activateRuntime?: boolean } {
1198+
function parseCursorModelSource(value: unknown): "sdk" | "cli" | "all" | null {
1199+
const source = asTrimmedString(value);
1200+
return source === "sdk" || source === "cli" || source === "all" ? source : null;
1201+
}
1202+
1203+
function parseChatModelsArgs(value: Record<string, unknown>): {
1204+
provider: AgentChatProvider;
1205+
activateRuntime?: boolean;
1206+
cursorSource?: "sdk" | "cli" | "all";
1207+
} {
1208+
const cursorSource = parseCursorModelSource(value.cursorSource);
11991209
return {
12001210
provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider,
12011211
...(value.activateRuntime === true ? { activateRuntime: true } : {}),
1212+
...(cursorSource ? { cursorSource } : {}),
12021213
};
12031214
}
12041215

12051216
function parseChatModelCatalogArgs(value: Record<string, unknown>): AgentChatModelCatalogArgs {
12061217
const mode = asTrimmedString(value.mode) as AgentChatModelCatalogMode | null;
12071218
const refreshProvider = asTrimmedString(value.refreshProvider) as AgentChatModelCatalogRefreshProvider | null;
1219+
const cursorSource = parseCursorModelSource(value.cursorSource);
12081220
return {
12091221
...(mode === "cached" || mode === "refresh-stale" || mode === "force" ? { mode } : {}),
12101222
...(
@@ -1216,6 +1228,7 @@ function parseChatModelCatalogArgs(value: Record<string, unknown>): AgentChatMod
12161228
? { refreshProvider }
12171229
: {}
12181230
),
1231+
...(cursorSource ? { cursorSource } : {}),
12191232
};
12201233
}
12211234

apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,9 @@ describe("getAvailableModels", () => {
312312
{
313313
domain: "chat",
314314
action: "getAvailableModels",
315-
args: { provider: "cursor", activateRuntime: true },
315+
// TUI chats run cursor through the SDK, so only that source is probed
316+
// synchronously; the CLI flavor revalidates in the background on the host.
317+
args: { provider: "cursor", activateRuntime: true, cursorSource: "sdk" },
316318
},
317319
]);
318320
});

apps/ade-cli/src/tuiClient/adeApi.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,9 @@ export async function getAvailableModels(
347347
// Codex is intentionally NOT here: its tiers come from the app-server, which
348348
// loadAvailableModels always queries regardless of activateRuntime.
349349
activateRuntime: provider === "cursor" || provider === "droid",
350+
// TUI chats run cursor models through the SDK; probing only that source
351+
// keeps the picker refresh off the slower cursor-agent CLI spawn.
352+
...(provider === "cursor" ? { cursorSource: "sdk" } : {}),
350353
});
351354
}
352355

apps/ade-cli/src/tuiClient/app.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4970,6 +4970,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
49704970
const catalog = await getModelCatalog(conn, {
49714971
mode: options.refreshProvider ? "refresh-stale" : "cached",
49724972
...(options.refreshProvider ? { refreshProvider: options.refreshProvider } : {}),
4973+
// TUI chats run cursor models through the SDK; refreshing only that
4974+
// source keeps the catalog probe off the slower host cursor-agent CLI
4975+
// spawn (mirrors getAvailableModels and the desktop ModelPicker).
4976+
...(options.refreshProvider === "cursor" ? { cursorSource: "sdk" as const } : {}),
49734977
});
49744978
modelCatalogRef.current = catalog;
49754979
setModelCatalog(catalog);
@@ -4980,6 +4984,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
49804984
void getModelCatalog(conn, {
49814985
mode: "force",
49824986
refreshProvider: options.refreshProvider,
4987+
...(options.refreshProvider === "cursor" ? { cursorSource: "sdk" as const } : {}),
49834988
}).then((freshCatalog) => {
49844989
if (connectionRef.current !== conn) return;
49854990
modelCatalogRef.current = freshCatalog;

apps/desktop/src/main/services/ai/aiIntegrationService.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const mockState = vi.hoisted(() => ({
88
buildProviderConnections: vi.fn(),
99
inspectLocalProvider: vi.fn(),
1010
clearCursorCliModelsCache: vi.fn(),
11+
markCursorModelCachesStale: vi.fn(),
1112
discoverCursorCliModelDescriptors: vi.fn(),
1213
discoverCursorSdkModelDescriptors: vi.fn(),
1314
probeCursorSdkModelDiscovery: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock("./localModelDiscovery", () => ({
4041

4142
vi.mock("../chat/cursorModelsDiscovery", () => ({
4243
clearCursorCliModelsCache: (...args: unknown[]) => mockState.clearCursorCliModelsCache(...args),
44+
markCursorModelCachesStale: (...args: unknown[]) => mockState.markCursorModelCachesStale(...args),
4345
discoverCursorCliModelDescriptors: (...args: unknown[]) => mockState.discoverCursorCliModelDescriptors(...args),
4446
discoverCursorSdkModelDescriptors: (...args: unknown[]) => mockState.discoverCursorSdkModelDescriptors(...args),
4547
probeCursorSdkModelDiscovery: (...args: unknown[]) => mockState.probeCursorSdkModelDiscovery(...args),
@@ -576,7 +578,10 @@ describe("aiIntegrationService", () => {
576578
});
577579
expect(refreshedStatus.availableProviders.cursor).toBe(true);
578580
expect(refreshedStatus.availableModelIds).toContain("cursor/auto");
579-
expect(mockState.clearCursorCliModelsCache).toHaveBeenCalled();
581+
// Verification ages the dynamic model caches without dropping
582+
// last-known-good rows; a full clear only happens on a key change.
583+
expect(mockState.markCursorModelCachesStale).toHaveBeenCalled();
584+
expect(mockState.clearCursorCliModelsCache).not.toHaveBeenCalled();
580585
expect(mockState.clearOpenCodeInventoryCache).toHaveBeenCalled();
581586
});
582587

apps/desktop/src/main/services/ai/aiIntegrationService.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ import { inspectLocalProvider } from "./localModelDiscovery";
6767
import {
6868
discoverCursorSdkModelDescriptors,
6969
clearCursorCliModelsCache,
70+
markCursorModelCachesStale,
7071
probeCursorSdkModelDiscovery,
7172
} from "../chat/cursorModelsDiscovery";
72-
import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../chat/droidModelsDiscovery";
73+
import { discoverDroidCliModelDescriptors, markDroidModelCachesStale } from "../chat/droidModelsDiscovery";
7374
import { resolveDroidExecutable } from "./droidExecutable";
7475
import { buildProviderConnections } from "./providerConnectionStatus";
7576
import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth";
@@ -999,7 +1000,10 @@ export function createAiIntegrationService(args: {
9991000

10001001
let available = getAvailableModels(auth);
10011002
const discoveryMode = options?.discoverCliModels === true ? "probe" : "cached-or-fallback";
1002-
const cursorDiscoveryMode = options?.discoverCliModels === true ? "probe" : "cached-only";
1003+
// "cached-or-fallback" serves last-known-good rows and warms the cache in
1004+
// the background when cold, so a verified key surfaces models on passive
1005+
// status reads (availableModelIds, mobile, TUI) without an active probe.
1006+
const cursorDiscoveryMode = options?.discoverCliModels === true ? "probe" : "cached-or-fallback";
10031007

10041008
const cursorApiKey = getCursorApiKeyFromAuth(auth);
10051009
if (cursorApiKey) {
@@ -1626,8 +1630,14 @@ export function createAiIntegrationService(args: {
16261630
resetProviderRuntimeHealth();
16271631
resetClaudeRuntimeProbeCache();
16281632
resetLocalProviderDetectionCache();
1629-
clearCursorCliModelsCache();
1630-
clearDroidCliModelsCache();
1633+
// Keep last-known-good cursor/droid model rows: generic readiness
1634+
// invalidation runs on every forced status refresh and on verifying ANY
1635+
// provider's key, and blanking the dynamic model lists here made cursor
1636+
// models vanish from every surface right after a successful verification.
1637+
// A cursor key change does a full clear at the storeApiKey/deleteApiKey
1638+
// call sites; droid auth is file-based, so it has no such call site.
1639+
markCursorModelCachesStale();
1640+
markDroidModelCachesStale();
16311641
clearOpenCodeBinaryCache();
16321642
clearOpenCodeInventoryCache();
16331643
replaceDynamicOpenCodeModelDescriptors([]);
@@ -1902,10 +1912,12 @@ export function createAiIntegrationService(args: {
19021912
verifyApiKeyConnection,
19031913
storeApiKey(provider: string, key: string): void {
19041914
storeStoredApiKey(provider, key);
1915+
if (provider.trim().toLowerCase() === "cursor") clearCursorCliModelsCache();
19051916
invalidateProviderReadinessCaches();
19061917
},
19071918
deleteApiKey(provider: string): void {
19081919
deleteStoredApiKey(provider);
1920+
if (provider.trim().toLowerCase() === "cursor") clearCursorCliModelsCache();
19091921
invalidateProviderReadinessCaches();
19101922
},
19111923
listApiKeys(): string[] {

apps/desktop/src/main/services/chat/agentChatService.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11084,6 +11084,17 @@ describe("createAgentChatService", () => {
1108411084
cursorAvailability: { cli: true, sdk: false },
1108511085
});
1108611086
expect(models[0]?.description).toContain("Cursor CLI");
11087+
11088+
// A surface that runs Cursor through the SDK (cursorSource: "sdk", e.g.
11089+
// TUI/mobile chat) must not be offered these CLI-only models — they'd
11090+
// fail on selection. With no SDK key configured, the sdk-scoped request
11091+
// returns nothing rather than leaking the CLI-only rows.
11092+
const sdkScoped = await service.getAvailableModels({
11093+
provider: "cursor",
11094+
activateRuntime: true,
11095+
cursorSource: "sdk",
11096+
});
11097+
expect(sdkScoped).toEqual([]);
1108711098
});
1108811099

1108911100
it("coalesces concurrent codex model discovery requests", async () => {

apps/desktop/src/main/services/chat/agentChatService.ts

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import type {
140140
AgentChatNoticeDetail,
141141
AgentChatInteractionMode,
142142
AgentChatInterruptArgs,
143+
AgentChatCursorModelSource,
143144
AgentChatModelCatalog,
144145
AgentChatModelCatalogArgs,
145146
AgentChatModelCatalogRefreshProvider,
@@ -23287,34 +23288,74 @@ export function createAgentChatService(args: {
2328723288
? MODEL_CATALOG_LOCAL_REFRESH_TTL_MS
2328823289
: MODEL_CATALOG_REFRESH_TTL_MS;
2328923290

23291+
// Cursor freshness is tracked per discovery source: an SDK-scoped refresh
23292+
// only proves the SDK rows are current, so it must not mark the CLI surface
23293+
// fresh (a later Work-tab CLI picker would otherwise skip its force probe
23294+
// for the TTL and miss CLI-only model/auth changes), and vice versa.
23295+
const cursorCatalogSourceRefreshedAt = new Map<"sdk" | "cli", number>();
23296+
const cursorSourcesFor = (cursorSource?: AgentChatCursorModelSource): ("sdk" | "cli")[] =>
23297+
cursorSource === "sdk" ? ["sdk"] : cursorSource === "cli" ? ["cli"] : ["sdk", "cli"];
23298+
2329023299
const markModelCatalogProviderFresh = (
2329123300
refreshProvider: AgentChatModelCatalogRefreshProvider | undefined,
2329223301
refreshedAt: number,
23302+
cursorSource?: AgentChatCursorModelSource,
2329323303
): void => {
23304+
if (refreshProvider === "cursor") {
23305+
for (const source of cursorSourcesFor(cursorSource)) {
23306+
cursorCatalogSourceRefreshedAt.set(source, refreshedAt);
23307+
}
23308+
return;
23309+
}
2329423310
if (refreshProvider) {
2329523311
modelCatalogProviderRefreshedAt.set(refreshProvider, refreshedAt);
2329623312
return;
2329723313
}
2329823314
for (const provider of MODEL_CATALOG_REFRESH_PROVIDERS) {
23299-
modelCatalogProviderRefreshedAt.set(provider, refreshedAt);
23315+
if (provider === "cursor") {
23316+
cursorCatalogSourceRefreshedAt.set("sdk", refreshedAt);
23317+
cursorCatalogSourceRefreshedAt.set("cli", refreshedAt);
23318+
} else {
23319+
modelCatalogProviderRefreshedAt.set(provider, refreshedAt);
23320+
}
2330023321
}
2330123322
};
2330223323

2330323324
const modelCatalogContainsRefreshProvider = (
2330423325
catalog: AgentChatModelCatalog,
2330523326
provider: AgentChatModelCatalogRefreshProvider,
23327+
cursorSource?: AgentChatCursorModelSource,
2330623328
): boolean => {
2330723329
return (catalog.groups ?? []).some((group) => {
2330823330
const groupMatches = group.key === provider;
2330923331
if (!groupMatches) return false;
23332+
// A cursor-flavored check must see rows the requesting surface can run:
23333+
// an SDK-only refresh must not satisfy a CLI-surface staleness probe.
23334+
if (provider === "cursor" && (cursorSource === "sdk" || cursorSource === "cli")) {
23335+
return (group.providers ?? []).some((entry) =>
23336+
(entry.subsections ?? []).some((subsection) =>
23337+
(subsection.models ?? []).some((model) => model.cursorAvailability?.[cursorSource] === true)));
23338+
}
2331023339
return (group.providers ?? []).some((entry) => entry.modelCount > 0);
2331123340
});
2331223341
};
2331323342

23314-
const isModelCatalogRefreshStale = (refreshProvider?: AgentChatModelCatalogRefreshProvider): boolean => {
23343+
const isModelCatalogRefreshStale = (
23344+
refreshProvider?: AgentChatModelCatalogRefreshProvider,
23345+
cursorSource?: AgentChatCursorModelSource,
23346+
): boolean => {
2331523347
if (!modelCatalogCache) return true;
23348+
if (refreshProvider === "cursor") {
23349+
if (!modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider, cursorSource)) return true;
23350+
// Stale unless every source the request covers was itself refreshed
23351+
// within the TTL — an "all" request needs both sdk and cli fresh.
23352+
const ttl = modelCatalogRefreshTtlMs(refreshProvider);
23353+
return cursorSourcesFor(cursorSource).some((source) => {
23354+
const refreshedAt = cursorCatalogSourceRefreshedAt.get(source);
23355+
return !refreshedAt || Date.now() - refreshedAt > ttl;
23356+
});
23357+
}
2331623358
if (refreshProvider) {
23317-
if (refreshProvider === "cursor" && !modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider)) return true;
2331823359
const refreshedAt = modelCatalogProviderRefreshedAt.get(refreshProvider);
2331923360
return !refreshedAt || Date.now() - refreshedAt > modelCatalogRefreshTtlMs(refreshProvider);
2332023361
}
@@ -23333,10 +23374,11 @@ export function createAgentChatService(args: {
2333323374
const shouldMarkModelCatalogProviderFresh = (
2333423375
catalog: AgentChatModelCatalog,
2333523376
refreshProvider: AgentChatModelCatalogRefreshProvider | undefined,
23377+
cursorSource?: AgentChatCursorModelSource,
2333623378
): boolean => {
2333723379
if (!refreshProvider) return true;
2333823380
if (refreshProvider !== "cursor") return true;
23339-
return modelCatalogContainsRefreshProvider(catalog, refreshProvider);
23381+
return modelCatalogContainsRefreshProvider(catalog, refreshProvider, cursorSource);
2334023382
};
2334123383

2334223384
const discoverOpenCodeLocalModels = async (): Promise<DiscoveredLocalModelEntry[]> => {
@@ -23368,6 +23410,7 @@ export function createAgentChatService(args: {
2336823410
const loadAvailableModels = async (args: {
2336923411
provider: AgentChatProvider;
2337023412
activateRuntime?: boolean;
23413+
cursorSource?: AgentChatCursorModelSource;
2337123414
}): Promise<AgentChatModelInfo[]> => {
2337223415
const provider = args.provider;
2337323416
if (provider === "codex") {
@@ -23387,19 +23430,40 @@ export function createAgentChatService(args: {
2338723430
const cursorCliPath = cursorCli?.type === "cli-subscription" && cursorCli.cli === "cursor"
2338823431
? cursorCli.path
2338923432
: null;
23433+
// Probe only the source the requesting surface runs models through —
23434+
// chat surfaces use the SDK (~300ms), CLI lane drafts use the CLI
23435+
// (a process spawn that can take seconds). The other source serves
23436+
// last-known-good rows and revalidates in the background, so a chat
23437+
// picker refresh never waits on a CLI spawn.
23438+
const cursorSource = args.cursorSource ?? "all";
23439+
const probeCli = args.activateRuntime === true && cursorSource !== "sdk";
23440+
const probeSdk = args.activateRuntime === true && cursorSource !== "cli";
2339023441
const [cliDescriptors, sdkDescriptors] = await Promise.all([
2339123442
cursorCliPath
2339223443
? discoverCursorCliModelDescriptors(cursorCliPath, {
23393-
mode: args.activateRuntime ? "probe" : "cached-or-fallback",
23444+
mode: probeCli ? "probe" : "cached-or-fallback",
2339423445
}).catch(() => [])
2339523446
: Promise.resolve([]),
2339623447
apiKey
2339723448
? discoverCursorSdkModelDescriptors(apiKey, {
23398-
mode: args.activateRuntime ? "probe" : "cached-only",
23449+
mode: probeSdk ? "probe" : "cached-or-fallback",
2339923450
}).catch(() => [])
2340023451
: Promise.resolve([]),
2340123452
]);
23402-
const ordered = mergeCursorModelDescriptorSources({ cliDescriptors, sdkDescriptors });
23453+
const merged = mergeCursorModelDescriptorSources({ cliDescriptors, sdkDescriptors });
23454+
// Honor the requested source in the RETURNED set, not just in probing.
23455+
// A surface that runs Cursor through one runtime must not be offered
23456+
// models only the other runtime can run — selecting one would later
23457+
// throw in assertCursorChatModelCanUseSdk (or the CLI equivalent). The
23458+
// skipped source still annotates availability on dual-capable rows;
23459+
// only rows exclusive to the skipped source are dropped. "all" returns
23460+
// the union. Surfaces that don't filter client-side (some TUI/mobile
23461+
// paths) rely on this.
23462+
const ordered = cursorSource === "sdk"
23463+
? merged.filter((d) => d.cursorAvailability?.sdk === true)
23464+
: cursorSource === "cli"
23465+
? merged.filter((d) => d.cursorAvailability?.cli === true)
23466+
: merged;
2340323467
const preferred = pickDefaultCursorDescriptorFromCliList(ordered);
2340423468
return ordered.map((d) => ({
2340523469
id: d.id,
@@ -23571,17 +23635,19 @@ export function createAgentChatService(args: {
2357123635
const getAvailableModels = async ({
2357223636
provider,
2357323637
activateRuntime,
23638+
cursorSource,
2357423639
}: {
2357523640
provider: AgentChatProvider;
2357623641
activateRuntime?: boolean;
23642+
cursorSource?: AgentChatCursorModelSource;
2357723643
}): Promise<AgentChatModelInfo[]> => {
23578-
const requestKey = `${provider}:${activateRuntime === true ? "active" : "passive"}`;
23644+
const requestKey = `${provider}:${activateRuntime === true ? "active" : "passive"}:${cursorSource ?? "all"}`;
2357923645
const existingRequest = availableModelsRequests.get(requestKey);
2358023646
if (existingRequest) {
2358123647
return existingRequest;
2358223648
}
2358323649

23584-
const request = loadAvailableModels({ provider, activateRuntime });
23650+
const request = loadAvailableModels({ provider, activateRuntime, cursorSource });
2358523651
availableModelsRequests.set(requestKey, request);
2358623652
try {
2358723653
return await request;
@@ -23595,7 +23661,7 @@ export function createAgentChatService(args: {
2359523661
const modelCatalogRequestKey = (catalogArgs?: AgentChatModelCatalogArgs): string => {
2359623662
const mode = catalogArgs?.mode ?? "refresh-stale";
2359723663
const refreshProvider = catalogArgs?.refreshProvider;
23598-
return `${mode}:${refreshProvider ?? "all"}`;
23664+
return `${mode}:${refreshProvider ?? "all"}:${catalogArgs?.cursorSource ?? "all"}`;
2359923665
};
2360023666

2360123667
const buildModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise<AgentChatModelCatalog> => {
@@ -23624,6 +23690,9 @@ export function createAgentChatService(args: {
2362423690
activateRuntime:
2362523691
(provider === "cursor" && shouldRefreshProvider("cursor"))
2362623692
|| (provider === "droid" && shouldRefreshProvider("droid")),
23693+
...(provider === "cursor" && catalogArgs?.cursorSource
23694+
? { cursorSource: catalogArgs.cursorSource }
23695+
: {}),
2362723696
}),
2362823697
};
2362923698
} catch {
@@ -23802,8 +23871,8 @@ export function createAgentChatService(args: {
2380223871
})),
2380323872
};
2380423873
modelCatalogCache = catalog;
23805-
if (mode !== "cached" && shouldMarkModelCatalogProviderFresh(catalog, refreshProvider)) {
23806-
markModelCatalogProviderFresh(refreshProvider, Date.now());
23874+
if (mode !== "cached" && shouldMarkModelCatalogProviderFresh(catalog, refreshProvider, catalogArgs?.cursorSource)) {
23875+
markModelCatalogProviderFresh(refreshProvider, Date.now(), catalogArgs?.cursorSource);
2380723876
}
2380823877
return catalog;
2380923878
};
@@ -23833,11 +23902,12 @@ export function createAgentChatService(args: {
2383323902
const getModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise<AgentChatModelCatalog> => {
2383423903
const mode = catalogArgs?.mode ?? "refresh-stale";
2383523904
if (mode === "refresh-stale" && modelCatalogCache) {
23836-
const stale = isModelCatalogRefreshStale(catalogArgs?.refreshProvider);
23905+
const stale = isModelCatalogRefreshStale(catalogArgs?.refreshProvider, catalogArgs?.cursorSource);
2383723906
if (stale) {
2383823907
scheduleModelCatalogRefresh({
2383923908
mode: "force",
2384023909
...(catalogArgs?.refreshProvider ? { refreshProvider: catalogArgs.refreshProvider } : {}),
23910+
...(catalogArgs?.cursorSource ? { cursorSource: catalogArgs.cursorSource } : {}),
2384123911
});
2384223912
}
2384323913
return withModelCatalogStaleFlag(modelCatalogCache, stale);

0 commit comments

Comments
 (0)