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:
gsxdsm
2026-07-09 00:12:21 -07:00
parent 2580524421
commit 6cff782308
9 changed files with 500 additions and 20 deletions

View 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.

View File

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

View File

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

View File

@@ -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");
});
});
}); });

View File

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

View File

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

View File

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

View File

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

View File

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