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);