diff --git a/.changeset/fn-7710-cli-provider-model-cache-refresh.md b/.changeset/fn-7710-cli-provider-model-cache-refresh.md
new file mode 100644
index 0000000000..987f3d3588
--- /dev/null
+++ b/.changeset/fn-7710-cli-provider-model-cache-refresh.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Grok and Cursor CLI models now appear in model pickers immediately after enabling the provider.
+category: fix
+dev: useModelsCache exposes a shared single-flight refreshModelsCache() that clears the SWR_CACHE_KEYS.MODELS cache and notifies subscribers; the Authentication CLI provider toggle (cursor-cli/grok-cli/claude-cli/llama-cpp) now calls it. Server-side cursor/grok picker caches use a short negative-TTL so transient cold-start empties self-heal.
diff --git a/packages/dashboard/app/components/__tests__/AuthenticationSection.modelsCacheRefresh.test.tsx b/packages/dashboard/app/components/__tests__/AuthenticationSection.modelsCacheRefresh.test.tsx
new file mode 100644
index 0000000000..d99a8a6bc2
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/AuthenticationSection.modelsCacheRefresh.test.tsx
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { AuthenticationSection, type AuthenticationSectionData } from "../settings/sections/AuthenticationSection";
+import type { AuthProvider } from "../../api";
+
+/*
+FNXC:ModelCatalog 2026-07-08-00:00:
+FN-7710 regression coverage: every CLI provider card's `onToggled` callback must refresh the
+shared model catalog (`refreshModelsCache()`) in addition to the Settings Authentication panel
+(`loadAuthStatus()`), so newly-enabled/disabled grok-cli/cursor-cli (and, for parity,
+claude-cli/llama-cpp) rows propagate to every live picker without a Settings navigation.
+This asserts the AuthenticationSection wiring only — the underlying refreshModelsCache()
+single-flight/notify semantics are covered in useModelsCache.test.ts.
+*/
+
+const loadAuthStatus = vi.fn();
+const refreshModelsCache = vi.fn().mockResolvedValue(undefined);
+
+vi.mock("../../hooks/useModelsCache", () => ({
+ refreshModelsCache: (...args: unknown[]) => refreshModelsCache(...args),
+}));
+
+vi.mock("../ProviderIcon", () => ({
+ ProviderIcon: ({ provider }: { provider: string }) => {provider},
+}));
+vi.mock("../PluginSlot", () => ({
+ PluginSlot: () => null,
+}));
+vi.mock("../CustomProvidersSection", () => ({
+ CustomProvidersSection: () => null,
+}));
+
+function mockCliCard(testId: string) {
+ return ({ onToggled }: { onToggled?: (nextEnabled: boolean) => void }) => (
+
+ );
+}
+
+vi.mock("../ClaudeCliProviderCard", () => ({
+ ClaudeCliProviderCard: mockCliCard("claude-cli-toggle"),
+}));
+vi.mock("../CursorCliProviderCard", () => ({
+ CursorCliProviderCard: mockCliCard("cursor-cli-toggle"),
+}));
+vi.mock("../GrokCliProviderCard", () => ({
+ GrokCliProviderCard: mockCliCard("grok-cli-toggle"),
+}));
+vi.mock("../LlamaCppProviderCard", () => ({
+ LlamaCppProviderCard: mockCliCard("llama-cpp-toggle"),
+}));
+
+function renderAuthSection(providers: AuthProvider[]) {
+ function Harness() {
+ const [apiKeyInputs, setApiKeyInputs] = useState>({});
+ const [manualCodeInputs, setManualCodeInputs] = useState>({});
+ const auth: AuthenticationSectionData = {
+ addToast: vi.fn(),
+ authProviders: providers,
+ authLoading: false,
+ authActionInProgress: null,
+ apiKeyInputs,
+ setApiKeyInputs,
+ apiKeyErrors: {},
+ opencodeApiKeyRefreshStatus: {},
+ deviceCodes: {},
+ loginInstructions: {},
+ manualCodeConfigs: {},
+ manualCodeInputs,
+ setManualCodeInputs,
+ manualCodeSubmitInProgress: null,
+ loadAuthStatus,
+ handleLogin: vi.fn(),
+ handleLogout: vi.fn(),
+ handleCancelLogin: vi.fn(),
+ handleSaveApiKey: vi.fn(),
+ handleClearApiKey: vi.fn(),
+ handleSubmitManualCode: vi.fn(),
+ };
+ return ;
+ }
+ render();
+}
+
+describe("AuthenticationSection CLI toggle -> shared models cache refresh (FN-7710)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("refreshes the shared models cache when grok-cli is toggled", async () => {
+ renderAuthSection([{ id: "grok-cli", name: "Grok — via Grok CLI", authenticated: false, type: "cli" }]);
+
+ screen.getByTestId("grok-cli-toggle").click();
+
+ expect(loadAuthStatus).toHaveBeenCalledTimes(1);
+ expect(refreshModelsCache).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes the shared models cache when cursor-cli is toggled", async () => {
+ renderAuthSection([{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: false, type: "cli" }]);
+
+ screen.getByTestId("cursor-cli-toggle").click();
+
+ expect(loadAuthStatus).toHaveBeenCalledTimes(1);
+ expect(refreshModelsCache).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes the shared models cache when claude-cli is toggled (parity)", async () => {
+ renderAuthSection([{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" }]);
+
+ screen.getByTestId("claude-cli-toggle").click();
+
+ expect(refreshModelsCache).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes the shared models cache when llama-cpp is toggled (parity)", async () => {
+ renderAuthSection([{ id: "llama-cpp", name: "Llama.cpp", authenticated: false, type: "cli" }]);
+
+ screen.getByTestId("llama-cpp-toggle").click();
+
+ expect(refreshModelsCache).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes on the disable transition too (onToggled fires for both directions)", async () => {
+ renderAuthSection([{ id: "grok-cli", name: "Grok — via Grok CLI", authenticated: true, type: "cli" }]);
+
+ // The mocked card's onToggled callback fires regardless of enable/disable direction —
+ // the real CursorCliProviderCard/GrokCliProviderCard call onToggled?.(result.enabled) on
+ // every successful toggle result, so a single shared handler covers both transitions.
+ screen.getByTestId("grok-cli-toggle").click();
+ screen.getByTestId("grok-cli-toggle").click();
+
+ expect(refreshModelsCache).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx
index 79499a7d51..da69ce5ce2 100644
--- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx
@@ -14,6 +14,7 @@ import { OAuthManualCodeForm } from "../../OAuthManualCodeForm";
import { CustomProvidersSection } from "../../CustomProvidersSection";
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
import { appendTokenQuery } from "../../../auth";
+import { refreshModelsCache } from "../../../hooks/useModelsCache";
export interface AuthenticationSectionData {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
@@ -92,25 +93,32 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
.sort(compareAuthProviderDisplayOrder);
const authenticatedProviders = sortedProviders.filter((p) => p.authenticated);
const unauthenticatedProviders = sortedProviders.filter((p) => !p.authenticated);
+ /*
+ FNXC:ModelCatalog 2026-07-08-00:00:
+ FN-7710: A CLI provider toggle (Cursor, Grok, Claude CLI, llama.cpp) must refresh the
+ shared model catalog so newly-enabled/disabled `*-cli` models appear in — or disappear
+ from — every live picker (Quick Entry, Task Detail, New Agent, Workflow editor, etc.)
+ without the user needing to navigate to Settings. `onToggled` previously only called
+ `loadAuthStatus()`, which refreshes this panel's own provider list but never touches the
+ shared `useModelsCache()` cache other pickers read from. All four CLI cards share this one
+ `onToggled` handler so the fix applies uniformly — no per-card duplication — and both the
+ enable and disable transitions call it (the cards invoke `onToggled` on every toggle result).
+ */
+ const handleCliProviderToggled = () => {
+ void loadAuthStatus();
+ void refreshModelsCache();
+ };
const renderCliProviderCard = (provider: AuthProvider) => {
if (provider.id === "claude-cli") {
- return ( {
- void loadAuthStatus();
- }}/>);
+ return ();
}
if (provider.id === "cursor-cli") {
- return ( {
- void loadAuthStatus();
- }}/>);
+ return ();
}
if (provider.id === "grok-cli") {
- return ( {
- void loadAuthStatus();
- }}/>);
+ return ();
}
- return ( {
- void loadAuthStatus();
- }}/>);
+ return ();
};
const showAuthenticatedGroup = authenticatedProviders.length > 0;
const showAvailableGroup = unauthenticatedProviders.length > 0;
diff --git a/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts b/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts
index dff711c885..d19bd9ca82 100644
--- a/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts
@@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
-import { useModelsCache } from "../useModelsCache";
+import { refreshModelsCache, useModelsCache } from "../useModelsCache";
vi.mock("../../api", () => ({
fetchModels: vi.fn(),
@@ -166,4 +166,161 @@ describe("useModelsCache", () => {
expect(mockFetchModels).toHaveBeenCalledTimes(2);
expect(result.current.models[0]?.id).toBe("claude");
});
+
+ describe("refreshModelsCache", () => {
+ // FN-7710 symptom reproduction: a CLI provider toggle (grok-cli / cursor-cli) must
+ // update every already-mounted useModelsCache() consumer without a remount.
+ it("updates every mounted useModelsCache() subscriber in place after a CLI provider toggle, for grok-cli", async () => {
+ // Seed the shared cache with a catalog that has NO grok-cli rows (pre-toggle state).
+ mockFetchModels.mockResolvedValueOnce({
+ models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+
+ const hookA = renderHook(() => useModelsCache());
+ const hookB = renderHook(() => useModelsCache());
+
+ await waitFor(() => {
+ expect(hookA.result.current.loading).toBe(false);
+ expect(hookB.result.current.loading).toBe(false);
+ });
+
+ // Before the fix's effect: neither consumer has any grok-cli rows yet.
+ expect(hookA.result.current.models.some((m) => m.provider === "grok-cli")).toBe(false);
+ expect(hookB.result.current.models.some((m) => m.provider === "grok-cli")).toBe(false);
+
+ // Simulate toggling Grok CLI on: fetchModels() now returns grok-cli rows too.
+ mockFetchModels.mockResolvedValueOnce({
+ models: [
+ { provider: "openai", id: "gpt-4o", name: "GPT-4o" },
+ { provider: "grok-cli", id: "grok-4", name: "Grok 4 (CLI)" },
+ ],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+
+ await act(async () => {
+ await refreshModelsCache();
+ });
+
+ // Both already-mounted consumers now see the grok-cli row, with no remount/navigation.
+ expect(hookA.result.current.models.some((m) => m.provider === "grok-cli" && m.id === "grok-4")).toBe(true);
+ expect(hookB.result.current.models.some((m) => m.provider === "grok-cli" && m.id === "grok-4")).toBe(true);
+
+ // Disabling propagates too: a subsequent refresh with grok-cli rows removed hides them again.
+ mockFetchModels.mockResolvedValueOnce({
+ models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+
+ await act(async () => {
+ await refreshModelsCache();
+ });
+
+ expect(hookA.result.current.models.some((m) => m.provider === "grok-cli")).toBe(false);
+ expect(hookB.result.current.models.some((m) => m.provider === "grok-cli")).toBe(false);
+ });
+
+ it("updates mounted subscribers after a cursor-cli toggle", async () => {
+ mockFetchModels.mockResolvedValueOnce({
+ models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+
+ const { result } = renderHook(() => useModelsCache());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.models.some((m) => m.provider === "cursor-cli")).toBe(false);
+
+ mockFetchModels.mockResolvedValueOnce({
+ models: [
+ { provider: "openai", id: "gpt-4o", name: "GPT-4o" },
+ { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5 (Cursor CLI)" },
+ ],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+
+ await act(async () => {
+ await refreshModelsCache();
+ });
+
+ expect(result.current.models.some((m) => m.provider === "cursor-cli")).toBe(true);
+ });
+
+ it("writes through SWR_CACHE_KEYS.MODELS on refresh", async () => {
+ mockFetchModels.mockResolvedValueOnce({
+ models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+ renderHook(() => useModelsCache());
+ await waitFor(() => expect(mockFetchModels).toHaveBeenCalledTimes(1));
+
+ mockFetchModels.mockResolvedValueOnce({
+ models: [{ provider: "grok-cli", id: "grok-4", name: "Grok 4" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+ await act(async () => {
+ await refreshModelsCache();
+ });
+
+ const cached = JSON.parse(localStorage.getItem(SWR_CACHE_KEYS.MODELS) ?? "null") as {
+ data: { models: Array<{ provider: string }> };
+ };
+ expect(cached.data.models.some((m) => m.provider === "grok-cli")).toBe(true);
+ });
+
+ it("single-flights concurrent refreshModelsCache() calls", async () => {
+ mockFetchModels.mockResolvedValueOnce({
+ models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+ renderHook(() => useModelsCache());
+ await waitFor(() => expect(mockFetchModels).toHaveBeenCalledTimes(1));
+
+ let resolveFetch: ((value: Awaited>) => void) | undefined;
+ mockFetchModels.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ );
+
+ const p1 = refreshModelsCache();
+ const p2 = refreshModelsCache();
+
+ expect(mockFetchModels).toHaveBeenCalledTimes(2); // 1 initial mount + 1 forced refresh (shared)
+
+ await act(async () => {
+ resolveFetch?.({
+ models: [{ provider: "grok-cli", id: "grok-4", name: "Grok 4" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+ await Promise.all([p1, p2]);
+ });
+
+ expect(mockFetchModels).toHaveBeenCalledTimes(2);
+ });
+
+ it("never throws and leaves an existing good list intact when the forced refresh fails", async () => {
+ mockFetchModels.mockResolvedValueOnce({
+ models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
+ favoriteProviders: [],
+ favoriteModels: [],
+ });
+ const { result } = renderHook(() => useModelsCache());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ mockFetchModels.mockRejectedValueOnce(new Error("network down"));
+
+ await expect(refreshModelsCache()).resolves.toBeUndefined();
+ expect(result.current.models[0]?.id).toBe("gpt-4o");
+ });
+ });
});
diff --git a/packages/dashboard/app/hooks/useModelsCache.ts b/packages/dashboard/app/hooks/useModelsCache.ts
index ead90e0488..3adba54ca7 100644
--- a/packages/dashboard/app/hooks/useModelsCache.ts
+++ b/packages/dashboard/app/hooks/useModelsCache.ts
@@ -24,6 +24,10 @@ const EMPTY_MODELS_STATE: ModelsCacheState = {
};
let inflight: Promise | null = null;
+// Guards concurrent refreshModelsCache() callers so they share one forced
+// fetch instead of each spawning its own request (see FNXC:ModelCatalog
+// comment on refreshModelsCache below).
+let refreshInflight: Promise | null = null;
const listeners = new Set<(state: ModelsCacheState) => void>();
function toModelsCacheState(response: ModelsResponse | null | undefined): ModelsCacheState {
@@ -53,13 +57,75 @@ function notifyListeners(state: ModelsCacheState): void {
async function fetchModelsShared(): Promise {
if (!inflight) {
- inflight = fetchModels().finally(() => {
- inflight = null;
- });
+ const promise = fetchModels();
+ inflight = promise;
+ // Only clear `inflight` if it still points at *this* promise — a forced
+ // refresh (see refreshModelsCache) may have already replaced it with a
+ // newer in-flight fetch, and this stale promise resolving later must not
+ // wipe out that newer reference out from under it. `.catch(() => {})`
+ // swallows the rejection on THIS bookkeeping branch only — the original
+ // `promise` returned below is untouched and still rejects for callers
+ // that await it, so error handling (try/catch in load()/refreshModelsCache)
+ // is unaffected; this just avoids a duplicate unhandled-rejection listener.
+ promise.then(
+ () => {
+ if (inflight === promise) inflight = null;
+ },
+ () => {
+ if (inflight === promise) inflight = null;
+ },
+ );
}
return inflight;
}
+/**
+ * FNXC:ModelCatalog 2026-07-08-00:00:
+ * FN-7710: Enabling/disabling a CLI provider (Grok, Cursor, Claude CLI,
+ * llama.cpp) changes which rows `/api/models` returns, but every already-
+ * mounted `useModelsCache()` consumer (board Quick Entry, Task Detail, New
+ * Agent, Workflow editor, etc.) only re-fetches on its own mount — it has no
+ * way to know the shared catalog is now stale. Previously the CLI provider
+ * cards' `onToggled` handler only refreshed the Settings Authentication
+ * panel (`loadAuthStatus()`), so newly-enabled `grok-cli`/`cursor-cli`
+ * models stayed invisible until some unrelated Settings surface happened to
+ * call `fetchModels()` fresh. `refreshModelsCache()` is the shared,
+ * single-flight, never-throw entry point any non-hook caller (a settings
+ * card, a toggle handler) can invoke to force a fresh fetch, write through
+ * `SWR_CACHE_KEYS.MODELS`, and notify every mounted `useModelsCache`
+ * subscriber via the module-level `listeners` set — no remount required.
+ * Concurrent `refreshModelsCache()` callers share one forced fetch via
+ * `refreshInflight`. A failed refresh must never blank an existing good
+ * list: on error this simply leaves the current cache/listeners state
+ * untouched, so a transient network hiccup degrades to "keep showing the
+ * last good list", not an empty picker.
+ */
+export async function refreshModelsCache(): Promise {
+ if (refreshInflight) {
+ return refreshInflight;
+ }
+
+ refreshInflight = (async () => {
+ try {
+ // Force a fresh fetch: drop any pre-toggle in-flight promise so a
+ // response requested before the provider toggle took effect is never
+ // mistaken for the post-toggle catalog.
+ inflight = null;
+ const response = await fetchModelsShared();
+ const nextState = toModelsCacheState(response);
+ writeCache(SWR_CACHE_KEYS.MODELS, response, { maxBytes: 500_000 });
+ notifyListeners(nextState);
+ } catch {
+ // Never throw, never blank an existing good list — leave cache/listeners
+ // state untouched on failure (see FNXC:ModelCatalog comment above).
+ }
+ })().finally(() => {
+ refreshInflight = null;
+ });
+
+ return refreshInflight;
+}
+
export function useModelsCache(): UseModelsCacheResult {
const cachedState = readCachedModelsState();
const [state, setState] = useState(() => cachedState ?? EMPTY_MODELS_STATE);
diff --git a/packages/dashboard/src/__tests__/cursor-model-cache.test.ts b/packages/dashboard/src/__tests__/cursor-model-cache.test.ts
index 561797c5a2..181a5d09ca 100644
--- a/packages/dashboard/src/__tests__/cursor-model-cache.test.ts
+++ b/packages/dashboard/src/__tests__/cursor-model-cache.test.ts
@@ -181,4 +181,38 @@ describe("getCursorPickerModels caching", () => {
expect(mockedDiscover).toHaveBeenCalledTimes(2);
});
+
+ // FN-7710: a transient cold-start empty/unavailable result must not poison
+ // the cache for the full 60s TTL — it self-heals after a much shorter
+ // negative-TTL window while a non-empty result keeps the normal TTL.
+ it("re-fetches an empty/unavailable result well before the full 60s TTL elapses", async () => {
+ mockedDiscover.mockResolvedValueOnce({ models: [], source: "probe", fallbackUsed: true, reason: "binary unavailable" });
+ let clock = 1000;
+ const now = () => clock;
+
+ const first = await getCursorPickerModels({ binaryPath: "cursor-test-8", ttlMs: 60_000, now });
+ expect(first).toEqual([]);
+
+ // Well past a short negative-TTL window, but far short of the full 60s TTL.
+ clock += 10_000;
+ mockedDiscover.mockResolvedValueOnce({ models: [{ id: "cursor/sonnet" }], source: "json", fallbackUsed: false });
+ const second = await getCursorPickerModels({ binaryPath: "cursor-test-8", ttlMs: 60_000, now });
+
+ expect(second).toEqual([
+ { provider: "cursor-cli", id: "cursor/sonnet", name: "cursor/sonnet", reasoning: false, contextWindow: 0 },
+ ]);
+ expect(mockedDiscover).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps a successful non-empty result cached for the full requested TTL (unlike an empty result)", async () => {
+ mockedDiscover.mockResolvedValueOnce({ models: [{ id: "cursor/sonnet" }], source: "json", fallbackUsed: false });
+ let clock = 1000;
+ const now = () => clock;
+
+ await getCursorPickerModels({ binaryPath: "cursor-test-9", ttlMs: 60_000, now });
+ clock += 10_000; // inside the 60s TTL for a non-empty result
+ await getCursorPickerModels({ binaryPath: "cursor-test-9", ttlMs: 60_000, now });
+
+ expect(mockedDiscover).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/packages/dashboard/src/__tests__/grok-model-cache.test.ts b/packages/dashboard/src/__tests__/grok-model-cache.test.ts
index 73bdfe1d7b..38b504f099 100644
--- a/packages/dashboard/src/__tests__/grok-model-cache.test.ts
+++ b/packages/dashboard/src/__tests__/grok-model-cache.test.ts
@@ -149,4 +149,37 @@ describe("getGrokPickerModels caching", () => {
expect(mockedDiscover).toHaveBeenCalledTimes(2);
});
+
+ // FN-7710: mirrors the Cursor picker cache negative-TTL hardening — a transient
+ // cold-start empty/unavailable result must not poison the cache for the full 60s TTL.
+ it("re-fetches an empty/unavailable result well before the full 60s TTL elapses", async () => {
+ mockedDiscover.mockResolvedValueOnce({ models: [], source: "probe", fallbackUsed: true, reason: "binary unavailable" });
+ let clock = 1000;
+ const now = () => clock;
+
+ const first = await getGrokPickerModels({ binaryPath: "grok-test-8", ttlMs: 60_000, now });
+ expect(first).toEqual([]);
+
+ // Well past a short negative-TTL window, but far short of the full 60s TTL.
+ clock += 10_000;
+ mockedDiscover.mockResolvedValueOnce({ models: [{ id: "grok-4" }], source: "models-text", fallbackUsed: false });
+ const second = await getGrokPickerModels({ binaryPath: "grok-test-8", ttlMs: 60_000, now });
+
+ expect(second).toEqual([
+ { provider: "grok-cli", id: "grok-4", name: "grok-4", reasoning: false, contextWindow: 0 },
+ ]);
+ expect(mockedDiscover).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps a successful non-empty result cached for the full requested TTL (unlike an empty result)", async () => {
+ mockedDiscover.mockResolvedValueOnce({ models: [{ id: "grok-4" }], source: "models-text", fallbackUsed: false });
+ let clock = 1000;
+ const now = () => clock;
+
+ await getGrokPickerModels({ binaryPath: "grok-test-9", ttlMs: 60_000, now });
+ clock += 10_000; // inside the 60s TTL for a non-empty result
+ await getGrokPickerModels({ binaryPath: "grok-test-9", ttlMs: 60_000, now });
+
+ expect(mockedDiscover).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/packages/dashboard/src/cursor-model-cache.ts b/packages/dashboard/src/cursor-model-cache.ts
index 4cfa9cd874..227bf34be8 100644
--- a/packages/dashboard/src/cursor-model-cache.ts
+++ b/packages/dashboard/src/cursor-model-cache.ts
@@ -51,6 +51,19 @@ export const CURSOR_PICKER_PROVIDER_ID = "cursor-cli" as const;
/** Default cache TTL for Cursor model discovery, in milliseconds. */
const DEFAULT_TTL_MS = 60_000;
+/**
+ * FNXC:ModelCatalog 2026-07-08-00:00:
+ * FN-7710: A transient cold-start empty/unavailable discovery result (e.g. the
+ * `cursor-agent` binary racing keychain/IDE warm-up right after the provider is toggled on)
+ * was previously cached for the full `DEFAULT_TTL_MS` (60s), same as a real successful
+ * result — so a first-load empty could persist for a minute even after the CLI became
+ * available. Empty/unavailable results now use this much shorter negative TTL so a
+ * transient cold-start empty self-heals quickly, while a non-empty successful discovery
+ * keeps using the normal 60s TTL. Single-flight and never-throw/never-spawn-per-request
+ * guarantees are unchanged — only how long an empty result is trusted.
+ */
+const EMPTY_RESULT_TTL_MS = 5_000;
+
/**
* Map Cursor CLI discovery output into the stable `/api/models` row shape.
*
@@ -95,6 +108,8 @@ interface CacheEntry {
fetchedAt: number;
/** The resolved (possibly empty, on failure/unavailability) model list. */
models: CursorPickerModel[];
+ /** The TTL that applies to this specific entry (short for empty results; see FN-7710). */
+ ttlMs: number;
}
/** Per-binaryPath cache of the most recently resolved Cursor picker models. */
@@ -149,7 +164,7 @@ export async function getCursorPickerModels(
const nowMs = now();
const cached = cache.get(binaryPath);
- if (cached && nowMs - cached.fetchedAt < ttlMs) {
+ if (cached && nowMs - cached.fetchedAt < cached.ttlMs) {
return cached.models;
}
@@ -177,7 +192,11 @@ export async function getCursorPickerModels(
try {
const models = await fetchPromise;
- cache.set(binaryPath, { fetchedAt: now(), models });
+ // FN-7710: empty/unavailable results use a short negative TTL so a
+ // transient cold-start empty self-heals quickly instead of persisting
+ // for the full 60s TTL (see FNXC:ModelCatalog comment above).
+ const effectiveTtlMs = models.length === 0 ? EMPTY_RESULT_TTL_MS : ttlMs;
+ cache.set(binaryPath, { fetchedAt: now(), models, ttlMs: effectiveTtlMs });
return models;
} finally {
inFlight.delete(binaryPath);
diff --git a/packages/dashboard/src/grok-model-cache.ts b/packages/dashboard/src/grok-model-cache.ts
index 9955a83adc..38c6574d4b 100644
--- a/packages/dashboard/src/grok-model-cache.ts
+++ b/packages/dashboard/src/grok-model-cache.ts
@@ -39,6 +39,19 @@ export const GROK_PICKER_PROVIDER_ID = "grok-cli" as const;
/** Default cache TTL for Grok model discovery, in milliseconds. */
const DEFAULT_TTL_MS = 60_000;
+/**
+ * FNXC:ModelCatalog 2026-07-08-00:00:
+ * FN-7710: mirrors the Cursor picker cache's negative-TTL hardening
+ * (cursor-model-cache.ts). A transient cold-start empty/unavailable discovery result was
+ * previously cached for the full `DEFAULT_TTL_MS` (60s), same as a real successful result —
+ * so a first-load empty right after the provider is toggled on could persist for a minute.
+ * Empty/unavailable results now use this much shorter negative TTL so a transient cold-start
+ * empty self-heals quickly, while a non-empty successful discovery keeps the normal 60s TTL.
+ * Single-flight and never-throw/never-spawn-per-request guarantees are unchanged — only how
+ * long an empty result is trusted.
+ */
+const EMPTY_RESULT_TTL_MS = 5_000;
+
/**
* Map Grok CLI discovery output into the stable `/api/models` row shape.
*
@@ -78,6 +91,8 @@ interface CacheEntry {
fetchedAt: number;
/** The resolved (possibly empty, on failure/unavailability) model list. */
models: GrokPickerModel[];
+ /** The TTL that applies to this specific entry (short for empty results; see FN-7710). */
+ ttlMs: number;
}
/** Per-binaryPath cache of the most recently resolved Grok picker models. */
@@ -130,7 +145,7 @@ export async function getGrokPickerModels(
const nowMs = now();
const cached = cache.get(binaryPath);
- if (cached && nowMs - cached.fetchedAt < ttlMs) {
+ if (cached && nowMs - cached.fetchedAt < cached.ttlMs) {
return cached.models;
}
@@ -158,7 +173,11 @@ export async function getGrokPickerModels(
try {
const models = await fetchPromise;
- cache.set(binaryPath, { fetchedAt: now(), models });
+ // FN-7710: empty/unavailable results use a short negative TTL so a
+ // transient cold-start empty self-heals quickly instead of persisting
+ // for the full 60s TTL (see FNXC:ModelCatalog comment above).
+ const effectiveTtlMs = models.length === 0 ? EMPTY_RESULT_TTL_MS : ttlMs;
+ cache.set(binaryPath, { fetchedAt: now(), models, ttlMs: effectiveTtlMs });
return models;
} finally {
inFlight.delete(binaryPath);