Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1195,16 +1195,28 @@ function parseConflictLaneArgs(value: Record<string, unknown>, action: string):
};
}

function parseChatModelsArgs(value: Record<string, unknown>): { 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<string, unknown>): {
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<string, unknown>): 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 } : {}),
...(
Expand All @@ -1216,6 +1228,7 @@ function parseChatModelCatalogArgs(value: Record<string, unknown>): AgentChatMod
? { refreshProvider }
: {}
),
...(cursorSource ? { cursorSource } : {}),
};
}

Expand Down
4 changes: 3 additions & 1 deletion apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
]);
});
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/tuiClient/adeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } : {}),
});
}

Expand Down
5 changes: 5 additions & 0 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();
});

Expand Down
20 changes: 16 additions & 4 deletions apps/desktop/src/main/services/ai/aiIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1626,8 +1630,14 @@ export function createAiIntegrationService(args: {
resetProviderRuntimeHealth();
resetClaudeRuntimeProbeCache();
resetLocalProviderDetectionCache();
clearCursorCliModelsCache();
clearDroidCliModelsCache();
// Keep last-known-good cursor/droid model rows: generic readiness
// invalidation runs on every forced status refresh and on verifying ANY
// provider's key, and blanking the dynamic model lists here made cursor
// models vanish from every surface right after a successful verification.
// A cursor key change does a full clear at the storeApiKey/deleteApiKey
// call sites; droid auth is file-based, so it has no such call site.
markCursorModelCachesStale();
markDroidModelCachesStale();
clearOpenCodeBinaryCache();
clearOpenCodeInventoryCache();
replaceDynamicOpenCodeModelDescriptors([]);
Expand Down Expand Up @@ -1902,10 +1912,12 @@ export function createAiIntegrationService(args: {
verifyApiKeyConnection,
storeApiKey(provider: string, key: string): void {
storeStoredApiKey(provider, key);
if (provider.trim().toLowerCase() === "cursor") clearCursorCliModelsCache();
invalidateProviderReadinessCaches();
},
deleteApiKey(provider: string): void {
deleteStoredApiKey(provider);
if (provider.trim().toLowerCase() === "cursor") clearCursorCliModelsCache();
invalidateProviderReadinessCaches();
},
listApiKeys(): string[] {
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
96 changes: 83 additions & 13 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ import type {
AgentChatNoticeDetail,
AgentChatInteractionMode,
AgentChatInterruptArgs,
AgentChatCursorModelSource,
AgentChatModelCatalog,
AgentChatModelCatalogArgs,
AgentChatModelCatalogRefreshProvider,
Expand Down Expand Up @@ -23287,34 +23288,74 @@ export function createAgentChatService(args: {
? MODEL_CATALOG_LOCAL_REFRESH_TTL_MS
: MODEL_CATALOG_REFRESH_TTL_MS;

// Cursor freshness is tracked per discovery source: an SDK-scoped refresh
// only proves the SDK rows are current, so it must not mark the CLI surface
// fresh (a later Work-tab CLI picker would otherwise skip its force probe
// for the TTL and miss CLI-only model/auth changes), and vice versa.
const cursorCatalogSourceRefreshedAt = new Map<"sdk" | "cli", number>();
const cursorSourcesFor = (cursorSource?: AgentChatCursorModelSource): ("sdk" | "cli")[] =>
cursorSource === "sdk" ? ["sdk"] : cursorSource === "cli" ? ["cli"] : ["sdk", "cli"];

const markModelCatalogProviderFresh = (
refreshProvider: AgentChatModelCatalogRefreshProvider | undefined,
refreshedAt: number,
cursorSource?: AgentChatCursorModelSource,
): void => {
if (refreshProvider === "cursor") {
for (const source of cursorSourcesFor(cursorSource)) {
cursorCatalogSourceRefreshedAt.set(source, refreshedAt);
}
return;
}
if (refreshProvider) {
modelCatalogProviderRefreshedAt.set(refreshProvider, refreshedAt);
return;
}
for (const provider of MODEL_CATALOG_REFRESH_PROVIDERS) {
modelCatalogProviderRefreshedAt.set(provider, refreshedAt);
if (provider === "cursor") {
cursorCatalogSourceRefreshedAt.set("sdk", refreshedAt);
cursorCatalogSourceRefreshedAt.set("cli", refreshedAt);
} else {
modelCatalogProviderRefreshedAt.set(provider, refreshedAt);
}
}
};

const modelCatalogContainsRefreshProvider = (
catalog: AgentChatModelCatalog,
provider: AgentChatModelCatalogRefreshProvider,
cursorSource?: AgentChatCursorModelSource,
): boolean => {
return (catalog.groups ?? []).some((group) => {
const groupMatches = group.key === provider;
if (!groupMatches) return false;
// A cursor-flavored check must see rows the requesting surface can run:
// an SDK-only refresh must not satisfy a CLI-surface staleness probe.
if (provider === "cursor" && (cursorSource === "sdk" || cursorSource === "cli")) {
return (group.providers ?? []).some((entry) =>
(entry.subsections ?? []).some((subsection) =>
(subsection.models ?? []).some((model) => model.cursorAvailability?.[cursorSource] === true)));
}
return (group.providers ?? []).some((entry) => entry.modelCount > 0);
});
};

const isModelCatalogRefreshStale = (refreshProvider?: AgentChatModelCatalogRefreshProvider): boolean => {
const isModelCatalogRefreshStale = (
refreshProvider?: AgentChatModelCatalogRefreshProvider,
cursorSource?: AgentChatCursorModelSource,
): boolean => {
if (!modelCatalogCache) return true;
if (refreshProvider === "cursor") {
if (!modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider, cursorSource)) return true;
// Stale unless every source the request covers was itself refreshed
// within the TTL — an "all" request needs both sdk and cli fresh.
const ttl = modelCatalogRefreshTtlMs(refreshProvider);
return cursorSourcesFor(cursorSource).some((source) => {
const refreshedAt = cursorCatalogSourceRefreshedAt.get(source);
return !refreshedAt || Date.now() - refreshedAt > ttl;
});
}
if (refreshProvider) {
if (refreshProvider === "cursor" && !modelCatalogContainsRefreshProvider(modelCatalogCache, refreshProvider)) return true;
const refreshedAt = modelCatalogProviderRefreshedAt.get(refreshProvider);
return !refreshedAt || Date.now() - refreshedAt > modelCatalogRefreshTtlMs(refreshProvider);
}
Expand All @@ -23333,10 +23374,11 @@ export function createAgentChatService(args: {
const shouldMarkModelCatalogProviderFresh = (
catalog: AgentChatModelCatalog,
refreshProvider: AgentChatModelCatalogRefreshProvider | undefined,
cursorSource?: AgentChatCursorModelSource,
): boolean => {
if (!refreshProvider) return true;
if (refreshProvider !== "cursor") return true;
return modelCatalogContainsRefreshProvider(catalog, refreshProvider);
return modelCatalogContainsRefreshProvider(catalog, refreshProvider, cursorSource);
};

const discoverOpenCodeLocalModels = async (): Promise<DiscoveredLocalModelEntry[]> => {
Expand Down Expand Up @@ -23368,6 +23410,7 @@ export function createAgentChatService(args: {
const loadAvailableModels = async (args: {
provider: AgentChatProvider;
activateRuntime?: boolean;
cursorSource?: AgentChatCursorModelSource;
}): Promise<AgentChatModelInfo[]> => {
const provider = args.provider;
if (provider === "codex") {
Expand All @@ -23387,19 +23430,40 @@ export function createAgentChatService(args: {
const cursorCliPath = cursorCli?.type === "cli-subscription" && cursorCli.cli === "cursor"
? cursorCli.path
: null;
// Probe only the source the requesting surface runs models through —
// chat surfaces use the SDK (~300ms), CLI lane drafts use the CLI
// (a process spawn that can take seconds). The other source serves
// last-known-good rows and revalidates in the background, so a chat
// picker refresh never waits on a CLI spawn.
const cursorSource = args.cursorSource ?? "all";
const probeCli = args.activateRuntime === true && cursorSource !== "sdk";
const probeSdk = args.activateRuntime === true && cursorSource !== "cli";
const [cliDescriptors, sdkDescriptors] = await Promise.all([
cursorCliPath
? discoverCursorCliModelDescriptors(cursorCliPath, {
mode: args.activateRuntime ? "probe" : "cached-or-fallback",
mode: probeCli ? "probe" : "cached-or-fallback",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter Cursor models to the requested source

When a caller requests cursorSource: "sdk" (TUI/mobile chat surfaces), probeCli is false but this still reads cached-or-fallback CLI descriptors, so any last-known-good CLI-only rows are merged into the returned model list. Those callers run Cursor through the SDK and do not all apply the desktop cursorAvailabilityMode filter, so a user can pick a CLI-only model that later fails in assertCursorChatModelCanUseSdk; the same leak can happen in the opposite direction for cursorSource: "cli". Filter the merged descriptors by the requested source, or use the skipped source only to annotate availability, not as selectable rows.

Useful? React with 👍 / 👎.

}).catch(() => [])
: Promise.resolve([]),
apiKey
? discoverCursorSdkModelDescriptors(apiKey, {
mode: args.activateRuntime ? "probe" : "cached-only",
mode: probeSdk ? "probe" : "cached-or-fallback",
}).catch(() => [])
: Promise.resolve([]),
]);
const ordered = mergeCursorModelDescriptorSources({ cliDescriptors, sdkDescriptors });
const merged = mergeCursorModelDescriptorSources({ cliDescriptors, sdkDescriptors });
// Honor the requested source in the RETURNED set, not just in probing.
// A surface that runs Cursor through one runtime must not be offered
// models only the other runtime can run — selecting one would later
// throw in assertCursorChatModelCanUseSdk (or the CLI equivalent). The
// skipped source still annotates availability on dual-capable rows;
// only rows exclusive to the skipped source are dropped. "all" returns
// the union. Surfaces that don't filter client-side (some TUI/mobile
// paths) rely on this.
const ordered = cursorSource === "sdk"
? merged.filter((d) => d.cursorAvailability?.sdk === true)
: cursorSource === "cli"
? merged.filter((d) => d.cursorAvailability?.cli === true)
: merged;
const preferred = pickDefaultCursorDescriptorFromCliList(ordered);
return ordered.map((d) => ({
id: d.id,
Expand Down Expand Up @@ -23571,17 +23635,19 @@ export function createAgentChatService(args: {
const getAvailableModels = async ({
provider,
activateRuntime,
cursorSource,
}: {
provider: AgentChatProvider;
activateRuntime?: boolean;
cursorSource?: AgentChatCursorModelSource;
}): Promise<AgentChatModelInfo[]> => {
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;
Expand All @@ -23595,7 +23661,7 @@ export function createAgentChatService(args: {
const modelCatalogRequestKey = (catalogArgs?: AgentChatModelCatalogArgs): string => {
const mode = catalogArgs?.mode ?? "refresh-stale";
const refreshProvider = catalogArgs?.refreshProvider;
return `${mode}:${refreshProvider ?? "all"}`;
return `${mode}:${refreshProvider ?? "all"}:${catalogArgs?.cursorSource ?? "all"}`;
};

const buildModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise<AgentChatModelCatalog> => {
Expand Down Expand Up @@ -23624,6 +23690,9 @@ export function createAgentChatService(args: {
activateRuntime:
(provider === "cursor" && shouldRefreshProvider("cursor"))
|| (provider === "droid" && shouldRefreshProvider("droid")),
...(provider === "cursor" && catalogArgs?.cursorSource
? { cursorSource: catalogArgs.cursorSource }
: {}),
}),
};
} catch {
Expand Down Expand Up @@ -23802,8 +23871,8 @@ export function createAgentChatService(args: {
})),
};
modelCatalogCache = catalog;
if (mode !== "cached" && shouldMarkModelCatalogProviderFresh(catalog, refreshProvider)) {
markModelCatalogProviderFresh(refreshProvider, Date.now());
if (mode !== "cached" && shouldMarkModelCatalogProviderFresh(catalog, refreshProvider, catalogArgs?.cursorSource)) {
markModelCatalogProviderFresh(refreshProvider, Date.now(), catalogArgs?.cursorSource);
}
return catalog;
};
Expand Down Expand Up @@ -23833,11 +23902,12 @@ export function createAgentChatService(args: {
const getModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise<AgentChatModelCatalog> => {
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);
Expand Down
Loading
Loading