From 814098852cbead843e000cd89fb11f5faa256302 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Apr 2026 13:14:02 +0800 Subject: [PATCH] feat: provider model freshness TTL and diagnostic UI (#426) * feat: add server-side TTL for provider snapshots and model list UI Provider snapshots cached forever, causing newly available models (e.g. OpenCode Go minimax, glm) to never appear in the picker. Server: ProviderSnapshotManager now returns stale data immediately and kicks off a background re-warm when the snapshot is older than 5 minutes. Injectable TTL/clock for testing. App: Provider settings rows show model count and are tappable to open a diagnostic sheet with a read-only model list (label + ID). * feat: add per-provider refresh for models and diagnostic * refactor: await refresh completion and clean up diagnostic sheet state - ProviderSnapshotManager.refresh is now async; session.ts awaits it so the RPC ACK reflects real completion instead of just queuing work. - Preserve models/modes/fetchedAt on entries during targeted refresh so the list no longer flashes empty mid-refresh. - Show "Updated Xs ago" next to the Models count, plus loading and error states for the list body. - Split resetSnapshotToLoading into full vs targeted branches. - Flatten nested ternary in model list rendering into renderModelsBody. - Drop redundant local refreshing state and cargo useMemo wrappers. * fix(app): widen isImeComposingKeyboardEvent type to accept optional fields TextInputKeyPressEventData has isComposing and keyCode as optional, so Pick was too narrow and broke typecheck on main. --- packages/app/public/index.html | 11 + .../src/components/adaptive-modal-sheet.tsx | 106 ++++- .../components/provider-diagnostic-sheet.tsx | 355 +++++++++++++--- .../src/components/spinning-refresh-icon.tsx | 67 +++ .../app/src/hooks/use-providers-snapshot.ts | 30 +- packages/app/src/screens/settings-screen.tsx | 104 +++-- packages/app/src/utils/keyboard-ime.ts | 9 +- packages/server/src/client/daemon-client.ts | 2 + .../agent/provider-snapshot-manager.test.ts | 401 +++++++++++++++++- .../server/agent/provider-snapshot-manager.ts | 100 ++++- packages/server/src/server/session.ts | 5 +- packages/server/src/shared/messages.ts | 1 + 12 files changed, 1044 insertions(+), 147 deletions(-) create mode 100644 packages/app/src/components/spinning-refresh-icon.tsx diff --git a/packages/app/public/index.html b/packages/app/public/index.html index 688fb0609..00c15da0c 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -51,6 +51,17 @@ [contenteditable='true'] { -webkit-app-region: no-drag !important; } + + /* Suppress the browser's default focus outline — it appears on mouse + clicks and looks out of place against our themed surfaces. Keep a + themed ring for keyboard users via :focus-visible. */ + *:focus { + outline: none; + } + *:focus-visible { + outline: 2px solid #20744A; + outline-offset: 2px; + } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index f8837fcfe..cb709a4e2 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -16,6 +16,35 @@ import { import { X } from "lucide-react-native"; import { isWeb } from "@/constants/platform"; +type EscHandler = () => void; +const escStack: EscHandler[] = []; +let escListenerAttached = false; + +function handleEscKeyDown(event: KeyboardEvent) { + if (event.key !== "Escape") return; + const top = escStack[escStack.length - 1]; + if (!top) return; + event.stopPropagation(); + event.preventDefault(); + top(); +} + +function pushEscHandler(handler: EscHandler): () => void { + escStack.push(handler); + if (!escListenerAttached && typeof window !== "undefined") { + window.addEventListener("keydown", handleEscKeyDown, true); + escListenerAttached = true; + } + return () => { + const index = escStack.lastIndexOf(handler); + if (index !== -1) escStack.splice(index, 1); + if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") { + window.removeEventListener("keydown", handleEscKeyDown, true); + escListenerAttached = false; + } + }; +} + const styles = StyleSheet.create((theme) => ({ desktopOverlay: { ...StyleSheet.absoluteFillObject, @@ -48,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.surface2, }, title: { + flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginLeft: theme.spacing[3], + marginRight: theme.spacing[2], + }, closeButton: { padding: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -83,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[6], gap: theme.spacing[4], }, + bottomSheetStaticContent: { + flex: 1, + padding: theme.spacing[6], + gap: theme.spacing[4], + minHeight: 0, + }, + desktopStaticContent: { + flexShrink: 1, + minHeight: 0, + padding: theme.spacing[6], + gap: theme.spacing[4], + }, })); function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -106,9 +155,11 @@ export interface AdaptiveModalSheetProps { visible: boolean; onClose: () => void; children: ReactNode; + headerActions?: ReactNode; snapPoints?: string[]; stackBehavior?: "push" | "switch" | "replace"; testID?: string; + scrollable?: boolean; } export function AdaptiveModalSheet({ @@ -116,9 +167,11 @@ export function AdaptiveModalSheet({ visible, onClose, children, + headerActions, snapPoints, stackBehavior, testID, + scrollable = true, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); @@ -157,6 +210,11 @@ export function AdaptiveModalSheet({ [], ); + useEffect(() => { + if (!isWeb || isMobile || !visible) return; + return pushEscHandler(onClose); + }, [visible, isMobile, onClose]); + if (isMobile) { return ( - {title} + + {title} + + {headerActions ? {headerActions} : null} - - {children} - + {scrollable ? ( + + {children} + + ) : ( + {children} + )} ); } @@ -199,19 +264,26 @@ export function AdaptiveModalSheet({ /> - {title} + + {title} + + {headerActions ? {headerActions} : null} - - {children} - + {scrollable ? ( + + {children} + + ) : ( + {children} + )} ); diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index d0e2fffac..418c22a63 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -1,11 +1,16 @@ -import { useCallback, useEffect, useState } from "react"; -import { View, Text, ActivityIndicator, ScrollView } from "react-native"; +import { AlertCircle, Search } from "lucide-react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; +import { isWeb } from "@/constants/platform"; +import { Fonts } from "@/constants/theme"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { resolveProviderLabel } from "@/utils/provider-definitions"; -import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { formatTimeAgo } from "@/utils/time"; +import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; interface ProviderDiagnosticSheetProps { provider: string; @@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({ }: ProviderDiagnosticSheetProps) { const { theme } = useUnistyles(); const client = useHostRuntimeClient(serverId); - const { entries: snapshotEntries } = useProvidersSnapshot(serverId); + const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId); const [diagnostic, setDiagnostic] = useState(null); const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(""); const providerLabel = resolveProviderLabel(provider, snapshotEntries); + const providerEntry = useMemo( + () => snapshotEntries?.find((entry) => entry.provider === provider), + [snapshotEntries, provider], + ); + const models = providerEntry?.models ?? []; + const providerSnapshotRefreshing = providerEntry?.status === "loading"; + const providerErrorMessage = + providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null; + const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading; - const fetchDiagnostic = useCallback(async () => { - if (!client || !provider) return; + const [clockTick, setClockTick] = useState(0); + useEffect(() => { + if (!visible) return; + const id = setInterval(() => setClockTick((t) => t + 1), 10_000); + return () => clearInterval(id); + }, [visible]); + const fetchedAtLabel = useMemo(() => { + if (!providerEntry?.fetchedAt) return null; + return formatTimeAgo(new Date(providerEntry.fetchedAt)); + // clockTick triggers re-computation on timer + }, [providerEntry?.fetchedAt, clockTick]); - setLoading(true); - setDiagnostic(null); + const q = query.trim().toLowerCase(); + const filteredModels = q + ? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) + : models; - try { - const result = await client.getProviderDiagnostic(provider as AgentProvider); - setDiagnostic(result.diagnostic); - } catch (err) { - setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); - } finally { - setLoading(false); - } - }, [client, provider]); + const fetchDiagnostic = useCallback( + async (options?: { keepCurrent?: boolean }) => { + if (!client || !provider) return; + + setLoading(true); + if (!options?.keepCurrent) { + setDiagnostic(null); + } + + try { + const result = await client.getProviderDiagnostic(provider as AgentProvider); + setDiagnostic(result.diagnostic); + } catch (err) { + setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); + } finally { + setLoading(false); + } + }, + [client, provider], + ); + + const handleRefresh = useCallback(() => { + void refresh([provider as AgentProvider]); + void fetchDiagnostic({ keepCurrent: true }); + }, [fetchDiagnostic, provider, refresh]); useEffect(() => { if (visible) { fetchDiagnostic(); } else { setDiagnostic(null); + setQuery(""); } }, [visible, fetchDiagnostic]); + function renderModelsBody() { + if (models.length === 0 && providerSnapshotRefreshing) { + return ( + + + Loading models… + + ); + } + if (models.length === 0 && providerErrorMessage) { + return ( + + + {providerErrorMessage} + + ); + } + if (models.length === 0) { + return ( + + No models detected. + + ); + } + if (filteredModels.length === 0) { + return ( + + + No models match your search + + ); + } + return filteredModels.map((model: AgentModelDefinition, index) => ( + 0 && sheetStyles.modelRowBorder]}> + + {model.label} + + + {model.id} + + + )); + } + return ( - {loading ? ( - - - Fetching diagnostic… - - ) : diagnostic ? ( - [ + sheetStyles.iconButton, + (hovered || pressed) && sheetStyles.iconButtonHovered, + refreshInFlight ? sheetStyles.disabled : null, + ]} + accessibilityRole="button" + accessibilityLabel={`Refresh ${providerLabel}`} > - - {diagnostic} - + + + } + > + + Diagnostic + + {loading && !diagnostic ? ( + + + Running diagnostic… + + ) : diagnostic ? ( + + + + {diagnostic} + + + + ) : ( + + No diagnostic available. + + )} + + + + + + Models + + {models.length} + {fetchedAtLabel ? ( + <> + · + Updated {fetchedAtLabel} + + ) : null} + + + {models.length > 0 ? ( + + + + + ) : null} + + {renderModelsBody()} - ) : null} + ); } const sheetStyles = StyleSheet.create((theme) => ({ - loadingContainer: { + section: { + gap: theme.spacing[2], + }, + sectionTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + mutedText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + iconButton: { + width: 30, + height: 30, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + iconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + disabled: { + opacity: 0.5, + }, + codeBlock: { + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + backgroundColor: theme.colors.surface2, + overflow: "hidden", + maxHeight: 180, + }, + codeScroll: { + maxHeight: 180, + }, + codeContent: { + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + }, + codeText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + lineHeight: 18, + }, + codeBlockLoading: { + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[3], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + modelsSection: { + flex: 1, + minHeight: 0, + gap: theme.spacing[2], + }, + modelsHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + modelsHeaderMeta: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + metaDot: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + countText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + searchContainer: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + paddingHorizontal: theme.spacing[3], + }, + searchInput: { + flex: 1, + paddingVertical: theme.spacing[2], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + modelsScroll: { + flex: 1, + minHeight: 0, + }, + modelsScrollContent: { + paddingBottom: theme.spacing[2], + }, + modelRow: { + paddingVertical: theme.spacing[3], + }, + modelRowBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + modelLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + modelId: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + fontFamily: Fonts.mono, + marginTop: 2, + }, + emptyState: { paddingVertical: theme.spacing[6], alignItems: "center", gap: theme.spacing[2], }, - loadingText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - paddingBottom: theme.spacing[4], - }, - diagnosticText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - fontFamily: "monospace", - lineHeight: theme.fontSize.sm * 1.6, - }, })); diff --git a/packages/app/src/components/spinning-refresh-icon.tsx b/packages/app/src/components/spinning-refresh-icon.tsx new file mode 100644 index 000000000..9060ac0a5 --- /dev/null +++ b/packages/app/src/components/spinning-refresh-icon.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { RefreshCw } from "lucide-react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +interface SpinningRefreshIconProps { + spinning: boolean; + size: number; + color: string; +} + +export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) { + const rotation = useSharedValue(0); + + useEffect(() => { + if (spinning) { + rotation.value = 0; + rotation.value = withRepeat( + withTiming(360, { + duration: 1000, + easing: Easing.linear, + }), + -1, + false, + ); + return; + } + + cancelAnimation(rotation); + const remainder = rotation.value % 360; + if (Math.abs(remainder) < 0.001) { + rotation.value = 0; + return; + } + + rotation.value = withTiming(360, { + duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)), + easing: Easing.linear, + }); + }, [rotation, spinning]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index afee8096b..af3878a77 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; import type { DaemonClient } from "@server/client/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionForServer } from "./use-session-directory"; @@ -14,9 +14,10 @@ interface UseProvidersSnapshotResult { entries: ProviderSnapshotEntry[] | undefined; isLoading: boolean; isFetching: boolean; + isRefreshing: boolean; error: string | null; supportsSnapshot: boolean; - refresh: () => void; + refresh: (providers?: AgentProvider[]) => Promise; invalidate: () => void; } @@ -43,6 +44,16 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps }, }); + const refreshMutation = useMutation({ + mutationFn: async (providers?: AgentProvider[]) => { + if (!client) { + return; + } + await client.refreshProvidersSnapshot({ providers }); + }, + }); + const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; + useEffect(() => { if (!supportsSnapshot || !client || !isConnected || !serverId) { return; @@ -60,12 +71,12 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps }); }, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]); - const refresh = useCallback(() => { - if (!client) { - return; - } - void client.refreshProvidersSnapshot(); - }, [client]); + const refresh = useCallback( + async (providers?: AgentProvider[]) => { + await refreshSnapshot(providers); + }, + [refreshSnapshot], + ); const invalidate = useCallback(() => { void queryClient.invalidateQueries({ queryKey }); @@ -75,6 +86,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps entries: snapshotQuery.data?.entries ?? undefined, isLoading: snapshotQuery.isLoading, isFetching: snapshotQuery.isFetching, + isRefreshing, error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, supportsSnapshot, refresh, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 638d7a9a1..655363eb1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -70,6 +70,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; import { StatusBadge } from "@/components/ui/status-badge"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { isWeb } from "@/constants/platform"; @@ -521,9 +522,11 @@ interface ProvidersSectionProps { function ProvidersSection({ routeServerId }: ProvidersSectionProps) { const { theme } = useUnistyles(); const isConnected = useHostRuntimeIsConnected(routeServerId); - const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); + const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId); const [diagnosticProvider, setDiagnosticProvider] = useState(null); const providerDefinitions = buildProviderDefinitions(entries); + const providerRefreshInFlight = + isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false); const hasServer = routeServerId.length > 0; @@ -534,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { Providers {hasServer && isConnected ? ( { + void refresh(); + }} + disabled={providerRefreshInFlight} + hitSlop={8} + style={({ hovered, pressed }) => [ + settingsStyles.sectionHeaderLink, + styles.providerRefreshButton, + (hovered || pressed) && styles.providerRefreshButtonHovered, + providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null, + ]} + accessibilityRole="button" + accessibilityLabel="Refresh providers" > - - Refresh - + ) : null} @@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { ? entry.error.trim() : null; + const modelCount = entry?.models?.length ?? 0; + return ( - + setDiagnosticProvider(def.id)} + accessibilityRole="button" + > ) : null} + {status === "ready" && modelCount > 0 ? ( + + {modelCount === 1 ? "1 model" : `${modelCount} models`} + + ) : null} - - - - - + + ); })} @@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({ formButtonPrimaryText: { color: theme.colors.palette.white, }, + providerRefreshButton: { + width: 30, + height: 30, + borderRadius: theme.borderRadius.full, + justifyContent: "center", + }, + providerRefreshButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + providerRefreshButtonDisabled: { + opacity: 0.5, + }, // Audio settings card audioCard: { overflow: "hidden", @@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], }, - providerActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/utils/keyboard-ime.ts b/packages/app/src/utils/keyboard-ime.ts index 7d1d7edfc..0e94262f0 100644 --- a/packages/app/src/utils/keyboard-ime.ts +++ b/packages/app/src/utils/keyboard-ime.ts @@ -1,5 +1,6 @@ -export function isImeComposingKeyboardEvent( - event: Pick, -): boolean { - return event.isComposing || event.keyCode === 229; +export function isImeComposingKeyboardEvent(event: { + isComposing?: boolean; + keyCode?: number; +}): boolean { + return Boolean(event.isComposing) || event.keyCode === 229; } diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 16edd9038..f0b5abe6c 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -2756,6 +2756,7 @@ export class DaemonClient { async refreshProvidersSnapshot(options?: { cwd?: string; + providers?: AgentProvider[]; requestId?: string; }): Promise { return this.sendCorrelatedSessionRequest({ @@ -2763,6 +2764,7 @@ export class DaemonClient { message: { type: "refresh_providers_snapshot_request", cwd: options?.cwd, + providers: options?.providers, }, responseType: "refresh_providers_snapshot_response", timeout: 5000, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 43d0cd095..41cc52ac7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -45,6 +45,7 @@ const TEST_CAPABILITIES = { } as const; describe("ProviderSnapshotManager", () => { + const ttlMs = 5 * 60 * 1_000; const projectCwd = resolve("/tmp/project"); const projectACwd = resolve("/tmp/project-a"); const projectBCwd = resolve("/tmp/project-b"); @@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => { ); }); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", @@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + test("refresh with providers only re-fetches matching providers", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const claudeFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("claude", "sonnet-4")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: claudeFetchModels, + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet-4", + ); + }); + + manager.refresh({ cwd: projectCwd, providers: ["codex"] }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({ + provider: "claude", + status: "ready", + models: [createModel("claude", "sonnet-4")], + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + expect(claudeFetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + + test("refresh treats an empty providers list as a full refresh", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const claudeFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("claude", "sonnet-4")]) + .mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: claudeFetchModels, + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + }); + + manager.refresh({ cwd: projectCwd, providers: [] }); + + expect(manager.getSnapshot(projectCwd)).toEqual([ + { + provider: "codex", + status: "loading", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, + { + provider: "claude", + status: "loading", + label: "claude", + description: "claude test provider", + defaultModeId: null, + }, + ]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet-4.5", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + expect(claudeFetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("refresh ignores provider filters that are not in the registry", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + }); + + manager.refresh({ cwd: projectCwd, providers: ["zai"] }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.1")], + }); + expect(codexFetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + test("refresh during an in-flight refresh is a no-op", async () => { const fetchModels = deferred(); const fetchModes = deferred(); @@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => { const changes: ProviderSnapshotEntry[][] = []; manager.on("change", (entries) => changes.push(entries)); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); expect(manager.getSnapshot(projectCwd)).toEqual([ { @@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => { }, ]); - manager.refresh(projectCwd); - manager.refresh(projectCwd); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); + manager.refresh({ cwd: projectCwd }); + manager.refresh({ cwd: projectCwd }); expect(changes).toHaveLength(1); expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); @@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => { + let now = 1_000; + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += ttlMs + 1; + + const staleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.1")], + modes: [createMode("auto")], + }); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + manager.destroy(); + }); + + test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => { + let now = 2_000; + const staleRefreshModels = deferred(); + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockImplementationOnce(async () => staleRefreshModels.promise); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += ttlMs + 1; + + const firstStaleSnapshot = manager.getSnapshot(projectCwd); + const secondStaleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => { + let now = 3_000; + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => [createModel("codex", "gpt-5.1")], + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + }); + + now += ttlMs - 1; + + const freshSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + + test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => { + let now = 4_000; + const unavailableFetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValue([createModel("codex", "gpt-5.2")]); + const unavailableIsAvailable = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const errorFetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockRejectedValueOnce(new Error("model lookup failed")) + .mockResolvedValueOnce([createModel("claude", "sonnet")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + isAvailable: unavailableIsAvailable, + fetchModels: async (cwd) => unavailableFetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: async (cwd) => errorFetchModels(cwd), + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe( + "unavailable", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error"); + }); + + now += ttlMs + 1; + + const staleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable"); + expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet", + ); + + manager.destroy(); + }); + + test("getSnapshot respects an injected TTL", async () => { + let now = 5_000; + const customTtlMs = 100; + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs: customTtlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += customTtlMs - 1; + manager.getSnapshot(projectCwd); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + + now += 2; + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + manager.destroy(); + }); + test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { const codexModels = deferred(); const { registry, handles } = createRegistry([ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 150cde42b..51586d5f1 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js" import type { ProviderDefinition } from "./provider-registry.js"; const DEFAULT_CWD_KEY = "__default__"; +const DEFAULT_SNAPSHOT_TTL_MS = 300_000; type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void; +type ProviderSnapshotManagerOptions = { + ttlMs?: number; + now?: () => number; +}; +type ProviderSnapshotRefreshOptions = { + cwd?: string; + providers?: AgentProvider[]; +}; export class ProviderSnapshotManager { private readonly snapshots = new Map>(); + private readonly lastCheckedAts = new Map(); private readonly warmUps = new Map>(); private readonly events = new EventEmitter(); private destroyed = false; + private readonly ttlMs: number; + private readonly now: () => number; constructor( private readonly providerRegistry: Record, private readonly logger: Logger, - ) {} + options: ProviderSnapshotManagerOptions = {}, + ) { + this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS; + this.now = options.now ?? Date.now; + } getSnapshot(cwd?: string): ProviderSnapshotEntry[] { const cwdKey = normalizeCwdKey(cwd); @@ -29,17 +45,24 @@ export class ProviderSnapshotManager { void this.warmUp(cwd); return entriesToArray(loadingEntries); } + if (this.shouldRevalidate(cwdKey)) { + void this.warmUp(cwd); + } return entriesToArray(entries); } - refresh(cwd?: string): void { + async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise { + const { cwd } = options; const cwdKey = normalizeCwdKey(cwd); - if (this.warmUps.has(cwdKey)) { + const inFlight = this.warmUps.get(cwdKey); + if (inFlight) { + await inFlight; return; } - this.resetSnapshotToLoading(cwdKey); + const providers = this.resolveRefreshProviders(options.providers); + this.resetSnapshotToLoading(cwdKey, providers); this.emitChange(cwdKey); - void this.warmUp(cwd); + await this.warmUp(cwd, providers); } on(event: "change", listener: ProviderSnapshotChangeListener): this { @@ -56,6 +79,7 @@ export class ProviderSnapshotManager { this.destroyed = true; this.events.removeAllListeners(); this.snapshots.clear(); + this.lastCheckedAts.clear(); this.warmUps.clear(); } @@ -74,16 +98,21 @@ export class ProviderSnapshotManager { return entries; } - private async warmUp(cwd?: string): Promise { + private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise { const cwdKey = normalizeCwdKey(cwd); const inFlight = this.warmUps.get(cwdKey); if (inFlight) { return inFlight; } + const providersToRefresh = providers ?? this.getProviderIds(); const warmUpPromise = Promise.allSettled( - this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)), - ).then(() => undefined); + providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)), + ).then(() => { + if (!providers) { + this.lastCheckedAts.set(cwdKey, this.now()); + } + }); this.warmUps.set(cwdKey, warmUpPromise); @@ -107,13 +136,6 @@ export class ProviderSnapshotManager { } const snapshot = this.getOrCreateSnapshot(cwdKey); - snapshot.set(provider, { - provider, - status: "loading", - label: definition.label, - description: definition.description, - defaultModeId: definition.defaultModeId, - }); try { const client = definition.createClient(this.logger); @@ -174,6 +196,17 @@ export class ProviderSnapshotManager { this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey)); } + private shouldRevalidate(cwdKey: string): boolean { + if (this.warmUps.has(cwdKey)) { + return false; + } + const lastCheckedAt = this.lastCheckedAts.get(cwdKey); + if (lastCheckedAt === undefined) { + return false; + } + return this.now() - lastCheckedAt > this.ttlMs; + } + private getOrCreateSnapshot(cwdKey: string): Map { const existing = this.snapshots.get(cwdKey); if (existing) { @@ -185,11 +218,31 @@ export class ProviderSnapshotManager { return created; } - private resetSnapshotToLoading(cwdKey: string): Map { + private resetSnapshotToLoading( + cwdKey: string, + providers?: AgentProvider[], + ): Map { const snapshot = this.getOrCreateSnapshot(cwdKey); - snapshot.clear(); - for (const [provider, entry] of this.createLoadingEntries()) { - snapshot.set(provider, entry); + const loadingEntries = this.createLoadingEntries(); + + if (!providers) { + snapshot.clear(); + for (const [provider, entry] of loadingEntries) { + snapshot.set(provider, entry); + } + return snapshot; + } + + for (const provider of providers) { + const loadingEntry = loadingEntries.get(provider); + if (!loadingEntry) continue; + const existing = snapshot.get(provider); + snapshot.set(provider, { + ...loadingEntry, + models: existing?.models, + modes: existing?.modes, + fetchedAt: existing?.fetchedAt, + }); } return snapshot; } @@ -197,6 +250,15 @@ export class ProviderSnapshotManager { private getProviderIds(): AgentProvider[] { return Object.keys(this.providerRegistry) as AgentProvider[]; } + + private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined { + if (!providers || providers.length === 0) { + return undefined; + } + + const providerIds = new Set(this.getProviderIds()); + return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider)); + } } function normalizeCwdKey(cwd?: string): string { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 08927cc92..039e678fe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3218,7 +3218,10 @@ export class Session { private async handleRefreshProvidersSnapshotRequest( msg: Extract, ): Promise { - this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined); + await this.providerSnapshotManager?.refresh({ + cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, + providers: msg.providers, + }); this.emit({ type: "refresh_providers_snapshot_response", payload: { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index f9a93e214..42d3e8e2a 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -852,6 +852,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({ export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ type: z.literal("refresh_providers_snapshot_request"), cwd: z.string().optional(), + providers: z.array(AgentProviderSchema).optional(), requestId: z.string(), });