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 { CustomProvidersSection } from "../../CustomProvidersSection";
|
||||||
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
|
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
|
||||||
import { appendTokenQuery } from "../../../auth";
|
import { appendTokenQuery } from "../../../auth";
|
||||||
|
import { refreshModelsCache } from "../../../hooks/useModelsCache";
|
||||||
export interface AuthenticationSectionData {
|
export interface AuthenticationSectionData {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
@@ -92,25 +93,32 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
|
|||||||
.sort(compareAuthProviderDisplayOrder);
|
.sort(compareAuthProviderDisplayOrder);
|
||||||
const authenticatedProviders = sortedProviders.filter((p) => p.authenticated);
|
const authenticatedProviders = sortedProviders.filter((p) => p.authenticated);
|
||||||
const unauthenticatedProviders = 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) => {
|
const renderCliProviderCard = (provider: AuthProvider) => {
|
||||||
if (provider.id === "claude-cli") {
|
if (provider.id === "claude-cli") {
|
||||||
return (<ClaudeCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
return (<ClaudeCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||||
void loadAuthStatus();
|
|
||||||
}}/>);
|
|
||||||
}
|
}
|
||||||
if (provider.id === "cursor-cli") {
|
if (provider.id === "cursor-cli") {
|
||||||
return (<CursorCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
return (<CursorCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||||
void loadAuthStatus();
|
|
||||||
}}/>);
|
|
||||||
}
|
}
|
||||||
if (provider.id === "grok-cli") {
|
if (provider.id === "grok-cli") {
|
||||||
return (<GrokCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
return (<GrokCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||||
void loadAuthStatus();
|
|
||||||
}}/>);
|
|
||||||
}
|
}
|
||||||
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
|
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
||||||
void loadAuthStatus();
|
|
||||||
}}/>);
|
|
||||||
};
|
};
|
||||||
const showAuthenticatedGroup = authenticatedProviders.length > 0;
|
const showAuthenticatedGroup = authenticatedProviders.length > 0;
|
||||||
const showAvailableGroup = unauthenticatedProviders.length > 0;
|
const showAvailableGroup = unauthenticatedProviders.length > 0;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
||||||
import { useModelsCache } from "../useModelsCache";
|
import { refreshModelsCache, useModelsCache } from "../useModelsCache";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchModels: vi.fn(),
|
fetchModels: vi.fn(),
|
||||||
@@ -166,4 +166,161 @@ describe("useModelsCache", () => {
|
|||||||
expect(mockFetchModels).toHaveBeenCalledTimes(2);
|
expect(mockFetchModels).toHaveBeenCalledTimes(2);
|
||||||
expect(result.current.models[0]?.id).toBe("claude");
|
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;
|
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>();
|
const listeners = new Set<(state: ModelsCacheState) => void>();
|
||||||
|
|
||||||
function toModelsCacheState(response: ModelsResponse | null | undefined): ModelsCacheState {
|
function toModelsCacheState(response: ModelsResponse | null | undefined): ModelsCacheState {
|
||||||
@@ -53,13 +57,75 @@ function notifyListeners(state: ModelsCacheState): void {
|
|||||||
|
|
||||||
async function fetchModelsShared(): Promise<ModelsResponse> {
|
async function fetchModelsShared(): Promise<ModelsResponse> {
|
||||||
if (!inflight) {
|
if (!inflight) {
|
||||||
inflight = fetchModels().finally(() => {
|
const promise = fetchModels();
|
||||||
inflight = null;
|
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;
|
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 {
|
export function useModelsCache(): UseModelsCacheResult {
|
||||||
const cachedState = readCachedModelsState();
|
const cachedState = readCachedModelsState();
|
||||||
const [state, setState] = useState<ModelsCacheState>(() => cachedState ?? EMPTY_MODELS_STATE);
|
const [state, setState] = useState<ModelsCacheState>(() => cachedState ?? EMPTY_MODELS_STATE);
|
||||||
|
|||||||
@@ -181,4 +181,38 @@ describe("getCursorPickerModels caching", () => {
|
|||||||
|
|
||||||
expect(mockedDiscover).toHaveBeenCalledTimes(2);
|
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);
|
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. */
|
/** Default cache TTL for Cursor model discovery, in milliseconds. */
|
||||||
const DEFAULT_TTL_MS = 60_000;
|
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.
|
* Map Cursor CLI discovery output into the stable `/api/models` row shape.
|
||||||
*
|
*
|
||||||
@@ -95,6 +108,8 @@ interface CacheEntry {
|
|||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
/** The resolved (possibly empty, on failure/unavailability) model list. */
|
/** The resolved (possibly empty, on failure/unavailability) model list. */
|
||||||
models: CursorPickerModel[];
|
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. */
|
/** Per-binaryPath cache of the most recently resolved Cursor picker models. */
|
||||||
@@ -149,7 +164,7 @@ export async function getCursorPickerModels(
|
|||||||
const nowMs = now();
|
const nowMs = now();
|
||||||
|
|
||||||
const cached = cache.get(binaryPath);
|
const cached = cache.get(binaryPath);
|
||||||
if (cached && nowMs - cached.fetchedAt < ttlMs) {
|
if (cached && nowMs - cached.fetchedAt < cached.ttlMs) {
|
||||||
return cached.models;
|
return cached.models;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +192,11 @@ export async function getCursorPickerModels(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const models = await fetchPromise;
|
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;
|
return models;
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.delete(binaryPath);
|
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. */
|
/** Default cache TTL for Grok model discovery, in milliseconds. */
|
||||||
const DEFAULT_TTL_MS = 60_000;
|
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.
|
* Map Grok CLI discovery output into the stable `/api/models` row shape.
|
||||||
*
|
*
|
||||||
@@ -78,6 +91,8 @@ interface CacheEntry {
|
|||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
/** The resolved (possibly empty, on failure/unavailability) model list. */
|
/** The resolved (possibly empty, on failure/unavailability) model list. */
|
||||||
models: GrokPickerModel[];
|
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. */
|
/** Per-binaryPath cache of the most recently resolved Grok picker models. */
|
||||||
@@ -130,7 +145,7 @@ export async function getGrokPickerModels(
|
|||||||
const nowMs = now();
|
const nowMs = now();
|
||||||
|
|
||||||
const cached = cache.get(binaryPath);
|
const cached = cache.get(binaryPath);
|
||||||
if (cached && nowMs - cached.fetchedAt < ttlMs) {
|
if (cached && nowMs - cached.fetchedAt < cached.ttlMs) {
|
||||||
return cached.models;
|
return cached.models;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +173,11 @@ export async function getGrokPickerModels(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const models = await fetchPromise;
|
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;
|
return models;
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.delete(binaryPath);
|
inFlight.delete(binaryPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user