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(),
});