FN-7710: refresh model caches so Grok/Cursor CLI models appear without reopening Settings
Adds a shared single-flight cache refresh so newly enabled Grok/Cursor CLI providers show their models in pickers immediately, instead of requiring a Settings reopen. - useModelsCache now exposes a shared refreshModelsCache() that clears the SWR MODELS cache key and notifies subscribers - AuthenticationSection calls refreshModelsCache() after toggling cursor-cli/grok-cli/claude-cli/llama-cpp providers - Server-side cursor/grok model-cache lookups use a short negative-TTL so transient cold-start empty results self-heal instead of sticking - Adds regression tests covering the cache refresh flow, hook behavior, and cursor/grok cache TTL self-healing - Adds changeset (patch) documenting the fix Files changed: .../fn-7710-cli-provider-model-cache-refresh.md | 7 + ...thenticationSection.modelsCacheRefresh.test.tsx | 137 ++++++++++++++++++ .../settings/sections/AuthenticationSection.tsx | 32 +++-- .../app/hooks/__tests__/useModelsCache.test.ts | 159 ++++++++++++++++++++- packages/dashboard/app/hooks/useModelsCache.ts | 72 +++++++++- .../src/__tests__/cursor-model-cache.test.ts | 34 +++++ .../src/__tests__/grok-model-cache.test.ts | 33 +++++ packages/dashboard/src/cursor-model-cache.ts | 23 ++- packages/dashboard/src/grok-model-cache.ts | 23 ++- 9 files changed, 500 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7710 Fusion-Task-Lineage: ebac46ba-5b3e-41f2-acc4-26f9139c0f71 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7710-cli-provider-model-cache-refresh.md
Normal file
7
.changeset/fn-7710-cli-provider-model-cache-refresh.md
Normal file
@@ -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.
|
||||
@@ -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 }) => <span data-testid={`mock-icon-${provider}`}>{provider}</span>,
|
||||
}));
|
||||
vi.mock("../PluginSlot", () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
vi.mock("../CustomProvidersSection", () => ({
|
||||
CustomProvidersSection: () => null,
|
||||
}));
|
||||
|
||||
function mockCliCard(testId: string) {
|
||||
return ({ onToggled }: { onToggled?: (nextEnabled: boolean) => void }) => (
|
||||
<button data-testid={testId} onClick={() => onToggled?.(true)}>
|
||||
toggle {testId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<string, string>>({});
|
||||
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
|
||||
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 <AuthenticationSection auth={auth} />;
|
||||
}
|
||||
render(<Harness />);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 (<ClaudeCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}/>);
|
||||
return (<ClaudeCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||
}
|
||||
if (provider.id === "cursor-cli") {
|
||||
return (<CursorCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}/>);
|
||||
return (<CursorCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||
}
|
||||
if (provider.id === "grok-cli") {
|
||||
return (<GrokCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}/>);
|
||||
return (<GrokCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||
}
|
||||
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}/>);
|
||||
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||
};
|
||||
const showAuthenticatedGroup = authenticatedProviders.length > 0;
|
||||
const showAvailableGroup = unauthenticatedProviders.length > 0;
|
||||
|
||||
@@ -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<ReturnType<typeof fetchModels>>) => 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,10 @@ const EMPTY_MODELS_STATE: ModelsCacheState = {
|
||||
};
|
||||
|
||||
let inflight: Promise<ModelsResponse> | 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<void> | 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<ModelsResponse> {
|
||||
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<void> {
|
||||
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<ModelsCacheState>(() => cachedState ?? EMPTY_MODELS_STATE);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user