Fix provider models per workspace (#1167)
* Scope provider snapshots by workspace * Fix provider snapshot cwd test on Windows
This commit is contained in:
+7
-3
@@ -36,11 +36,15 @@ Draft metadata lookups should avoid creating provider sessions when the upstream
|
|||||||
|
|
||||||
## Provider Snapshot Refresh Contract
|
## Provider Snapshot Refresh Contract
|
||||||
|
|
||||||
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
|
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
|
||||||
|
|
||||||
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||||
|
|
||||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
|
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||||
|
|
||||||
|
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||||
|
|
||||||
|
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1645,7 +1645,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
refetchIfStale: refetchSnapshotIfStale,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
} = useProvidersSnapshot(serverId);
|
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||||
|
|
||||||
const snapshotSelectedEntry = useMemo(
|
const snapshotSelectedEntry = useMemo(
|
||||||
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
|
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ export function ImportSessionSheet({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||||
|
cwd,
|
||||||
enabled: visible,
|
enabled: visible,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ function buildChatDraftComposerArgs({
|
|||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
initialServerId: serverId || null,
|
initialServerId: serverId || null,
|
||||||
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
initialValues:
|
||||||
|
workspaceDirectory || sourceDirectory
|
||||||
|
? { workingDir: workspaceDirectory || sourceDirectory }
|
||||||
|
: undefined,
|
||||||
isVisible: pendingWorkspaceSetup !== null,
|
isVisible: pendingWorkspaceSetup !== null,
|
||||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||||
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
|
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isProvidersSnapshotHomeScope,
|
||||||
|
normalizeProvidersSnapshotCwd,
|
||||||
|
providersSnapshotQueryKey,
|
||||||
|
providersSnapshotQueryRoot,
|
||||||
|
providersSnapshotRequestOptions,
|
||||||
|
} from "./providers-snapshot-query";
|
||||||
|
|
||||||
|
describe("providers snapshot query scope", () => {
|
||||||
|
it("normalizes blank cwd values to the home scope", () => {
|
||||||
|
expect(normalizeProvidersSnapshotCwd(undefined)).toBeNull();
|
||||||
|
expect(normalizeProvidersSnapshotCwd(null)).toBeNull();
|
||||||
|
expect(normalizeProvidersSnapshotCwd(" ")).toBeNull();
|
||||||
|
expect(isProvidersSnapshotHomeScope("")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps home and workspace query keys separate under one server root", () => {
|
||||||
|
expect(providersSnapshotQueryRoot("server-1")).toEqual(["providersSnapshot", "server-1"]);
|
||||||
|
expect(providersSnapshotQueryKey("server-1")).toEqual([
|
||||||
|
"providersSnapshot",
|
||||||
|
"server-1",
|
||||||
|
"home",
|
||||||
|
]);
|
||||||
|
expect(providersSnapshotQueryKey("server-1", "/repo-a")).toEqual([
|
||||||
|
"providersSnapshot",
|
||||||
|
"server-1",
|
||||||
|
"cwd",
|
||||||
|
"/repo-a",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds request options with cwd only for workspace scopes", () => {
|
||||||
|
expect(providersSnapshotRequestOptions({ cwd: null, providers: ["codex"] })).toEqual({
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
expect(providersSnapshotRequestOptions({ cwd: "/repo-a", providers: ["codex"] })).toEqual({
|
||||||
|
cwd: "/repo-a",
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
export const PROVIDERS_SNAPSHOT_QUERY_ROOT = "providersSnapshot";
|
||||||
|
|
||||||
|
export function normalizeProvidersSnapshotCwd(cwd?: string | null): string | null {
|
||||||
|
const trimmed = cwd?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providersSnapshotQueryRoot(serverId: string | null) {
|
||||||
|
return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
||||||
|
const normalizedCwd = normalizeProvidersSnapshotCwd(cwd);
|
||||||
|
return normalizedCwd
|
||||||
|
? ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "cwd", normalizedCwd] as const)
|
||||||
|
: ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "home"] as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providersSnapshotRequestOptions(input: {
|
||||||
|
cwd?: string | null;
|
||||||
|
providers?: AgentProvider[];
|
||||||
|
}) {
|
||||||
|
const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd);
|
||||||
|
return {
|
||||||
|
...(normalizedCwd ? { cwd: normalizedCwd } : {}),
|
||||||
|
...(input.providers ? { providers: input.providers } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProvidersSnapshotHomeScope(cwd?: string | null): boolean {
|
||||||
|
return normalizeProvidersSnapshotCwd(cwd) === null;
|
||||||
|
}
|
||||||
@@ -211,7 +211,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
error: snapshotError,
|
error: snapshotError,
|
||||||
refresh: refreshSnapshot,
|
refresh: refreshSnapshot,
|
||||||
refetchIfStale: refetchSnapshotIfStale,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
} = useProvidersSnapshot(formState.serverId);
|
} = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir });
|
||||||
|
|
||||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
const snapshotProviderDefinitions = useMemo(
|
const snapshotProviderDefinitions = useMemo(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers
|
|||||||
interface ProviderSnapshotUpdateMessage {
|
interface ProviderSnapshotUpdateMessage {
|
||||||
type: "providers_snapshot_update";
|
type: "providers_snapshot_update";
|
||||||
payload: {
|
payload: {
|
||||||
cwd: string;
|
cwd?: string;
|
||||||
entries: ProviderSnapshotEntry[];
|
entries: ProviderSnapshotEntry[];
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -76,12 +76,12 @@ function enableProvidersSnapshot(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProvidersSnapshotHook() {
|
function renderProvidersSnapshotHook(options: { cwd?: string | null } = {}) {
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||||
|
|
||||||
return renderHook(() => useProvidersSnapshot(serverId), { wrapper });
|
return renderHook(() => useProvidersSnapshot(serverId, options), { wrapper });
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
|
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
|
||||||
@@ -123,7 +123,7 @@ async function waitForSnapshotEntries(
|
|||||||
|
|
||||||
async function emitProvidersSnapshotUpdate(
|
async function emitProvidersSnapshotUpdate(
|
||||||
entries: ProviderSnapshotEntry[],
|
entries: ProviderSnapshotEntry[],
|
||||||
cwd = "/repo",
|
cwd?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const listener = snapshotUpdateListeners.at(-1);
|
const listener = snapshotUpdateListeners.at(-1);
|
||||||
expect(listener).toBeDefined();
|
expect(listener).toBeDefined();
|
||||||
@@ -132,7 +132,7 @@ async function emitProvidersSnapshotUpdate(
|
|||||||
listener?.({
|
listener?.({
|
||||||
type: "providers_snapshot_update",
|
type: "providers_snapshot_update",
|
||||||
payload: {
|
payload: {
|
||||||
cwd,
|
...(cwd ? { cwd } : {}),
|
||||||
entries,
|
entries,
|
||||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||||
},
|
},
|
||||||
@@ -155,8 +155,14 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("providers snapshot hook cache scope", () => {
|
describe("providers snapshot hook cache scope", () => {
|
||||||
it("uses a global query key without cwd", () => {
|
it("uses separate query keys for home and workspace scopes", () => {
|
||||||
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId]);
|
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]);
|
||||||
|
expect(providersSnapshotQueryKey(serverId, "/repo-a")).toEqual([
|
||||||
|
"providersSnapshot",
|
||||||
|
serverId,
|
||||||
|
"cwd",
|
||||||
|
"/repo-a",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
||||||
@@ -181,7 +187,7 @@ describe("providers snapshot hook cache scope", () => {
|
|||||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send cwd for repeated snapshot loads and refreshes", async () => {
|
it("sends cwd for workspace snapshot loads and refreshes", async () => {
|
||||||
enableProvidersSnapshot();
|
enableProvidersSnapshot();
|
||||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||||
@@ -189,29 +195,34 @@ describe("providers snapshot hook cache scope", () => {
|
|||||||
requestId: "workspace-refresh",
|
requestId: "workspace-refresh",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderProvidersSnapshotHook();
|
const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo-a" });
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.refresh(["codex"]);
|
await result.current.refresh(["codex"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
||||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
cwd: "/repo-a",
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo-a" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies provider snapshot updates from other cwd values to the global cache", async () => {
|
it("routes provider snapshot updates by cwd scope", async () => {
|
||||||
enableProvidersSnapshot();
|
enableProvidersSnapshot();
|
||||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||||
|
|
||||||
const { result } = renderProvidersSnapshotHook();
|
const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" });
|
||||||
|
|
||||||
await waitForSnapshotEntries(result, []);
|
await waitForSnapshotEntries(result, []);
|
||||||
await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b");
|
await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b");
|
||||||
|
expect(result.current.entries).toEqual([]);
|
||||||
|
|
||||||
|
await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-a");
|
||||||
await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]);
|
await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import type { DaemonClient } from "@server/client/daemon-client";
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||||
|
import {
|
||||||
|
isProvidersSnapshotHomeScope,
|
||||||
|
normalizeProvidersSnapshotCwd,
|
||||||
|
providersSnapshotQueryKey,
|
||||||
|
providersSnapshotQueryRoot,
|
||||||
|
providersSnapshotRequestOptions,
|
||||||
|
} from "@/hooks/providers-snapshot-query";
|
||||||
|
|
||||||
export function providersSnapshotQueryKey(serverId: string | null) {
|
export { providersSnapshotQueryKey, providersSnapshotQueryRoot };
|
||||||
return ["providersSnapshot", serverId] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseProvidersSnapshotResult {
|
interface UseProvidersSnapshotResult {
|
||||||
entries: ProviderSnapshotEntry[] | undefined;
|
entries: ProviderSnapshotEntry[] | undefined;
|
||||||
@@ -23,6 +28,7 @@ interface UseProvidersSnapshotResult {
|
|||||||
|
|
||||||
interface UseProvidersSnapshotOptions {
|
interface UseProvidersSnapshotOptions {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
cwd?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProvidersSnapshot(
|
export function useProvidersSnapshot(
|
||||||
@@ -33,11 +39,14 @@ export function useProvidersSnapshot(
|
|||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const enabled = options.enabled ?? true;
|
const enabled = options.enabled ?? true;
|
||||||
|
const cwd = normalizeProvidersSnapshotCwd(options.cwd);
|
||||||
const supportsSnapshot = useSessionStore(
|
const supportsSnapshot = useSessionStore(
|
||||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
|
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
|
||||||
|
const queryRoot = useMemo(() => providersSnapshotQueryRoot(serverId), [serverId]);
|
||||||
|
const requestOptions = useMemo(() => providersSnapshotRequestOptions({ cwd }), [cwd]);
|
||||||
|
|
||||||
const snapshotQuery = useQuery({
|
const snapshotQuery = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -47,7 +56,7 @@ export function useProvidersSnapshot(
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return client.getProvidersSnapshot({});
|
return client.getProvidersSnapshot(requestOptions);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +65,7 @@ export function useProvidersSnapshot(
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await client.refreshProvidersSnapshot(providers ? { providers } : {});
|
await client.refreshProvidersSnapshot(providersSnapshotRequestOptions({ cwd, providers }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||||
@@ -70,7 +79,8 @@ export function useProvidersSnapshot(
|
|||||||
if (message.type !== "providers_snapshot_update") {
|
if (message.type !== "providers_snapshot_update") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queryClient.setQueryData(queryKey, {
|
const updateQueryKey = providersSnapshotQueryKey(serverId, message.payload.cwd);
|
||||||
|
queryClient.setQueryData(updateQueryKey, {
|
||||||
entries: message.payload.entries,
|
entries: message.payload.entries,
|
||||||
generatedAt: message.payload.generatedAt,
|
generatedAt: message.payload.generatedAt,
|
||||||
requestId: "providers_snapshot_update",
|
requestId: "providers_snapshot_update",
|
||||||
@@ -78,13 +88,13 @@ export function useProvidersSnapshot(
|
|||||||
const shouldRefetch = message.payload.entries.some((entry) => entry.status === "loading");
|
const shouldRefetch = message.payload.entries.some((entry) => entry.status === "loading");
|
||||||
if (shouldRefetch) {
|
if (shouldRefetch) {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey,
|
queryKey: updateQueryKey,
|
||||||
exact: true,
|
exact: true,
|
||||||
refetchType: "active",
|
refetchType: "active",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [client, enabled, isConnected, queryClient, queryKey, serverId, supportsSnapshot]);
|
}, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]);
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (providers?: AgentProvider[]) => {
|
async (providers?: AgentProvider[]) => {
|
||||||
@@ -92,10 +102,13 @@ export function useProvidersSnapshot(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshSnapshot(providers);
|
await refreshSnapshot(providers);
|
||||||
const snapshot = await client.getProvidersSnapshot({});
|
if (isProvidersSnapshotHomeScope(cwd)) {
|
||||||
|
queryClient.removeQueries({ queryKey: queryRoot, exact: false });
|
||||||
|
}
|
||||||
|
const snapshot = await client.getProvidersSnapshot(requestOptions);
|
||||||
queryClient.setQueryData(queryKey, snapshot);
|
queryClient.setQueryData(queryKey, snapshot);
|
||||||
},
|
},
|
||||||
[client, queryClient, queryKey, refreshSnapshot],
|
[client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchIfStale = useCallback(
|
const refetchIfStale = useCallback(
|
||||||
@@ -131,11 +144,17 @@ export function useProvidersSnapshot(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
|
export function prefetchProvidersSnapshot(
|
||||||
const queryKey = providersSnapshotQueryKey(serverId);
|
serverId: string,
|
||||||
|
client: DaemonClient,
|
||||||
|
options: { cwd?: string | null } = {},
|
||||||
|
): void {
|
||||||
|
const cwd = normalizeProvidersSnapshotCwd(options.cwd);
|
||||||
|
const queryKey = providersSnapshotQueryKey(serverId, cwd);
|
||||||
|
const requestOptions = providersSnapshotRequestOptions({ cwd });
|
||||||
void singletonQueryClient.prefetchQuery({
|
void singletonQueryClient.prefetchQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: () => client.getProvidersSnapshot({}),
|
queryFn: () => client.getProvidersSnapshot(requestOptions),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,7 +381,10 @@ function buildComposerConfig(input: {
|
|||||||
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
|
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
|
||||||
return {
|
return {
|
||||||
initialServerId: serverId || null,
|
initialServerId: serverId || null,
|
||||||
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
initialValues:
|
||||||
|
workspaceDirectory || sourceDirectory
|
||||||
|
? { workingDir: workspaceDirectory || sourceDirectory }
|
||||||
|
: undefined,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||||
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
|
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
|
||||||
|
|||||||
@@ -1502,8 +1502,9 @@ function WorkspaceScreenContent({
|
|||||||
setIsImportSheetVisible(false);
|
setIsImportSheetVisible(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Warm the global provider snapshot so the model picker is ready when opened.
|
// Warm the workspace-scoped provider snapshot so the model picker is ready when opened.
|
||||||
useProvidersSnapshot(normalizedServerId, {
|
useProvidersSnapshot(normalizedServerId, {
|
||||||
|
cwd: workspaceDirectory,
|
||||||
enabled: isRouteFocused,
|
enabled: isRouteFocused,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
expect(changes).toHaveLength(1);
|
expect(changes).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(changes[0]?.cwd).toBe(homedir());
|
expect(changes[0]?.cwd).toBe(projectCwd);
|
||||||
expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready");
|
expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready");
|
||||||
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
|
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
|
||||||
|
|
||||||
@@ -542,19 +542,19 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchModels).toHaveBeenCalledWith(homedir(), false);
|
expect(fetchModels).toHaveBeenCalledWith(projectCwd, false);
|
||||||
expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true);
|
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
||||||
|
|
||||||
loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]);
|
loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]);
|
||||||
await warmUpPromise;
|
await warmUpPromise;
|
||||||
|
|
||||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true);
|
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("settings refresh refreshes the single global provider state once", async () => {
|
test("settings refresh clears workspace scopes and refreshes only home immediately", async () => {
|
||||||
const fetchModels = vi
|
const fetchModels = vi
|
||||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
.mockImplementation(async (_cwd, force) => [
|
.mockImplementation(async (_cwd, force) => [
|
||||||
@@ -585,22 +585,31 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||||
|
|
||||||
expect(fetchModels.mock.calls).toEqual([
|
expect(fetchModels.mock.calls).toEqual([
|
||||||
[homedir(), false],
|
[projectACwd, false],
|
||||||
|
[projectBCwd, false],
|
||||||
[homedir(), true],
|
[homedir(), true],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const projectASnapshot = manager.getSnapshot(projectACwd);
|
const homeSnapshot = manager.getSnapshot();
|
||||||
expect(getProviderEntry(projectASnapshot, "codex")).toMatchObject({
|
expect(getProviderEntry(homeSnapshot, "codex")).toMatchObject({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
models: [createModel("codex", "refreshed")],
|
models: [createModel("codex", "refreshed")],
|
||||||
});
|
});
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
const projectASnapshot = manager.getSnapshot(projectACwd);
|
||||||
|
expect(getProviderEntry(projectASnapshot, "codex")).toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
expect(getProviderEntry(projectASnapshot, "codex")?.models).toBeUndefined();
|
||||||
expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready");
|
expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready");
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("settings refresh updates workspace reads through the shared global provider state", async () => {
|
test("settings refresh makes workspace reads refetch on demand", async () => {
|
||||||
const fetchModels = vi
|
const fetchModels = vi
|
||||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
.mockImplementation(async (_cwd, force) => [
|
.mockImplementation(async (_cwd, force) => [
|
||||||
@@ -624,19 +633,59 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||||
|
|
||||||
expect(fetchModels.mock.calls).toEqual([
|
expect(fetchModels.mock.calls).toEqual([
|
||||||
[homedir(), false],
|
[projectCwd, false],
|
||||||
[homedir(), true],
|
[homedir(), true],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
"refreshed",
|
"initial",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("settings refresh prevents stale in-flight workspace loads from repopulating the cache", async () => {
|
||||||
|
const workspaceModels = deferred<AgentModelDefinition[]>();
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockImplementationOnce(async () => workspaceModels.promise)
|
||||||
|
.mockImplementationOnce(async (_cwd, force) => [
|
||||||
|
createModel("codex", force ? "home-refreshed" : "unexpected"),
|
||||||
|
]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd, force) => fetchModels(cwd, force),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||||
|
workspaceModels.resolve([createModel("codex", "stale-workspace-model")]);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"home-refreshed",
|
||||||
|
);
|
||||||
|
const projectEntry = getProviderEntry(manager.getSnapshot(projectCwd), "codex");
|
||||||
|
expect(projectEntry).toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
expect(projectEntry?.models).toBeUndefined();
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("refresh marks a slow provider as error after the timeout", async () => {
|
test("refresh marks a slow provider as error after the timeout", async () => {
|
||||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
@@ -748,7 +797,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
expect(unavailableIsAvailable).toHaveBeenCalledTimes(1);
|
expect(unavailableIsAvailable).toHaveBeenCalledTimes(1);
|
||||||
expect(errorFetchModels).toHaveBeenCalledTimes(1);
|
expect(errorFetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await manager.refreshSettingsSnapshot({ providers: ["codex", "claude"] });
|
await manager.refreshSnapshotForCwd({ cwd: projectCwd, providers: ["codex", "claude"] });
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
@@ -810,7 +859,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
expect(nextHandles.zai?.createClient).not.toHaveBeenCalled();
|
expect(nextHandles.zai?.createClient).not.toHaveBeenCalled();
|
||||||
expect(nextHandles.zai?.fetchModels).not.toHaveBeenCalled();
|
expect(nextHandles.zai?.fetchModels).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await manager.refreshSettingsSnapshot({ providers: ["zai"] });
|
await manager.refreshSnapshotForCwd({ cwd: projectCwd, providers: ["zai"] });
|
||||||
|
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({
|
||||||
provider: "zai",
|
provider: "zai",
|
||||||
@@ -858,7 +907,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("different cwd keys share the same global provider snapshot state", async () => {
|
test("different cwd keys keep independent provider snapshot state", async () => {
|
||||||
const seenCwds: string[] = [];
|
const seenCwds: string[] = [];
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
@@ -880,12 +929,12 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe(
|
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
`model:${homedir()}`,
|
`model:${projectACwd}`,
|
||||||
);
|
);
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
`model:${homedir()}`,
|
`model:${projectBCwd}`,
|
||||||
);
|
);
|
||||||
expect(seenCwds).toEqual([homedir()]);
|
expect(seenCwds).toEqual([projectACwd, projectBCwd]);
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
@@ -917,7 +966,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace cwd does not affect global provider model fetching", async () => {
|
test("workspace cwd is normalized before provider model fetching", async () => {
|
||||||
const seenCwds: string[] = [];
|
const seenCwds: string[] = [];
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
@@ -934,15 +983,18 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.getSnapshot("relative-provider-test/..");
|
manager.getSnapshot("relative-provider-test/..");
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(seenCwds).toHaveLength(1);
|
expect(seenCwds).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(seenCwds).toEqual([homedir()]);
|
expect(seenCwds).toEqual([
|
||||||
|
resolve(homedir(), "paseo-provider-test"),
|
||||||
|
resolve("relative-provider-test/.."),
|
||||||
|
]);
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace refresh refreshes the shared global provider state with force true", async () => {
|
test("workspace refresh refreshes only that cwd with force true", async () => {
|
||||||
const fetchModels = vi
|
const fetchModels = vi
|
||||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
.mockImplementation(async (_cwd, force) => [
|
.mockImplementation(async (_cwd, force) => [
|
||||||
@@ -960,17 +1012,18 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.getSnapshot(projectBCwd);
|
manager.getSnapshot(projectBCwd);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
expect(fetchModels).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] });
|
await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] });
|
||||||
|
|
||||||
expect(fetchModels.mock.calls).toEqual([
|
expect(fetchModels.mock.calls).toEqual([
|
||||||
[homedir(), false],
|
[projectACwd, false],
|
||||||
[homedir(), true],
|
[projectBCwd, false],
|
||||||
|
[projectACwd, true],
|
||||||
]);
|
]);
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
"refreshed",
|
"initial",
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
@@ -1013,7 +1066,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
homedir(),
|
projectCwd,
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export class ProviderSnapshotManager {
|
|||||||
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
|
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(_cwd?: string): ProviderSnapshotEntry[] {
|
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||||
const resolvedCwd = resolveGlobalSnapshotCwd();
|
const resolvedCwd = resolveSnapshotCwd(cwd);
|
||||||
const entries = this.snapshots.get(resolvedCwd);
|
const entries = this.snapshots.get(resolvedCwd);
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
|
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
|
||||||
@@ -54,9 +54,14 @@ export class ProviderSnapshotManager {
|
|||||||
}
|
}
|
||||||
const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
|
const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
|
||||||
if (missingProviders.length > 0) {
|
if (missingProviders.length > 0) {
|
||||||
|
const loadingEntries = this.createLoadingEntries();
|
||||||
for (const provider of missingProviders) {
|
for (const provider of missingProviders) {
|
||||||
entries.set(provider, this.createUnprobedEntry(provider));
|
const loadingEntry = loadingEntries.get(provider);
|
||||||
|
if (loadingEntry) {
|
||||||
|
entries.set(provider, loadingEntry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
void this.warmUp(resolvedCwd, missingProviders);
|
||||||
}
|
}
|
||||||
const providerLoads = this.providerLoads.get(resolvedCwd);
|
const providerLoads = this.providerLoads.get(resolvedCwd);
|
||||||
const loadingProviders = Array.from(entries.values())
|
const loadingProviders = Array.from(entries.values())
|
||||||
@@ -69,9 +74,9 @@ export class ProviderSnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
const snapshotCwd = resolveGlobalSnapshotCwd();
|
const snapshotCwd = resolveSnapshotCwd(options.cwd);
|
||||||
const providers = this.resolveRefreshProviders(options.providers);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
this.resetSnapshotToLoading(snapshotCwd, providers);
|
this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false });
|
||||||
this.emitChange(snapshotCwd);
|
this.emitChange(snapshotCwd);
|
||||||
await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds());
|
await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds());
|
||||||
}
|
}
|
||||||
@@ -79,17 +84,18 @@ export class ProviderSnapshotManager {
|
|||||||
async refreshSettingsSnapshot(
|
async refreshSettingsSnapshot(
|
||||||
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
|
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const homeCwd = resolveGlobalSnapshotCwd();
|
const homeCwd = resolveSnapshotCwd();
|
||||||
const providers = this.resolveRefreshProviders(options.providers);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
const providersToRefresh = providers ?? this.getProviderIds();
|
const providersToRefresh = providers ?? this.getProviderIds();
|
||||||
|
|
||||||
this.resetSnapshotToLoading(homeCwd, providers);
|
this.clearCachedProviders(providers);
|
||||||
|
this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false });
|
||||||
this.emitChange(homeCwd);
|
this.emitChange(homeCwd);
|
||||||
await this.refreshProviders(homeCwd, providersToRefresh);
|
await this.refreshProviders(homeCwd, providersToRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
const snapshotCwd = resolveGlobalSnapshotCwd();
|
const snapshotCwd = resolveSnapshotCwd(options.cwd);
|
||||||
const providers = this.resolveRefreshProviders(options.providers);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
if (options.providers && providers?.length === 0) {
|
if (options.providers && providers?.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -155,18 +161,6 @@ export class ProviderSnapshotManager {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createUnprobedEntry(provider: AgentProvider): ProviderSnapshotEntry {
|
|
||||||
const definition = this.providerRegistry[provider];
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
status: "unavailable",
|
|
||||||
enabled: definition?.enabled ?? true,
|
|
||||||
label: definition?.label,
|
|
||||||
description: definition?.description,
|
|
||||||
defaultModeId: definition?.defaultModeId ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private reconcileSnapshotForRegistry(cwd: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
private reconcileSnapshotForRegistry(cwd: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||||
const existing = this.snapshots.get(cwd);
|
const existing = this.snapshots.get(cwd);
|
||||||
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
|
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
|
||||||
@@ -214,6 +208,47 @@ export class ProviderSnapshotManager {
|
|||||||
await this.loadProviders({ cwd, providers, force: true });
|
await this.loadProviders({ cwd, providers, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearCachedProviders(providers?: AgentProvider[]): void {
|
||||||
|
const providerSet = providers ? new Set(providers) : null;
|
||||||
|
const loadingEntries = this.createLoadingEntries();
|
||||||
|
|
||||||
|
for (const [cwd, providerLoads] of Array.from(this.providerLoads.entries())) {
|
||||||
|
if (!providerSet) {
|
||||||
|
this.providerLoads.delete(cwd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of providerSet) {
|
||||||
|
providerLoads.delete(provider);
|
||||||
|
}
|
||||||
|
if (providerLoads.size === 0) {
|
||||||
|
this.providerLoads.delete(cwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [cwd, snapshot] of this.snapshots.entries()) {
|
||||||
|
if (!providerSet) {
|
||||||
|
snapshot.clear();
|
||||||
|
for (const [provider, entry] of loadingEntries) {
|
||||||
|
snapshot.set(provider, entry);
|
||||||
|
}
|
||||||
|
this.emitChange(cwd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const provider of providerSet) {
|
||||||
|
const loadingEntry = loadingEntries.get(provider);
|
||||||
|
if (!loadingEntry) continue;
|
||||||
|
snapshot.set(provider, loadingEntry);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.emitChange(cwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadProviders(options: ProviderLoadOptions): Promise<void> {
|
private async loadProviders(options: ProviderLoadOptions): Promise<void> {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
options.providers.map((provider) => this.loadProvider({ ...options, provider })),
|
options.providers.map((provider) => this.loadProvider({ ...options, provider })),
|
||||||
@@ -374,9 +409,11 @@ export class ProviderSnapshotManager {
|
|||||||
private resetSnapshotToLoading(
|
private resetSnapshotToLoading(
|
||||||
cwdKey: string,
|
cwdKey: string,
|
||||||
providers?: AgentProvider[],
|
providers?: AgentProvider[],
|
||||||
|
options: { preserveExisting?: boolean } = {},
|
||||||
): Map<AgentProvider, ProviderSnapshotEntry> {
|
): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||||
const loadingEntries = this.createLoadingEntries();
|
const loadingEntries = this.createLoadingEntries();
|
||||||
|
const preserveExisting = options.preserveExisting ?? true;
|
||||||
|
|
||||||
if (!providers) {
|
if (!providers) {
|
||||||
snapshot.clear();
|
snapshot.clear();
|
||||||
@@ -392,9 +429,13 @@ export class ProviderSnapshotManager {
|
|||||||
const existing = snapshot.get(provider);
|
const existing = snapshot.get(provider);
|
||||||
snapshot.set(provider, {
|
snapshot.set(provider, {
|
||||||
...loadingEntry,
|
...loadingEntry,
|
||||||
models: existing?.models,
|
...(preserveExisting
|
||||||
modes: existing?.modes,
|
? {
|
||||||
fetchedAt: existing?.fetchedAt,
|
models: existing?.models,
|
||||||
|
modes: existing?.modes,
|
||||||
|
fetchedAt: existing?.fetchedAt,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -424,10 +465,6 @@ export function resolveSnapshotCwd(cwd?: string | null): string {
|
|||||||
return resolve(expanded);
|
return resolve(expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGlobalSnapshotCwd(): string {
|
|
||||||
return resolveSnapshotCwd();
|
|
||||||
}
|
|
||||||
|
|
||||||
function entriesToArray(
|
function entriesToArray(
|
||||||
entries: Map<AgentProvider, ProviderSnapshotEntry>,
|
entries: Map<AgentProvider, ProviderSnapshotEntry>,
|
||||||
): ProviderSnapshotEntry[] {
|
): ProviderSnapshotEntry[] {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { execSync } from "child_process";
|
|||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||||
import { homedir, tmpdir } from "os";
|
import { homedir, tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join, resolve as resolvePath } from "path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
@@ -986,6 +986,42 @@ describe("session provider refresh cwd routing", () => {
|
|||||||
expect(refreshSettingsSnapshot).not.toHaveBeenCalled();
|
expect(refreshSettingsSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("provider snapshot requests pass cwd through to provider discovery", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const workspaceCwd = resolvePath("/tmp/session-provider-snapshot");
|
||||||
|
const fetchModels = vi.fn(async (options: ListModelsOptions) => [
|
||||||
|
{
|
||||||
|
provider: "codex" as const,
|
||||||
|
id: `model:${options.cwd}`,
|
||||||
|
label: `model:${options.cwd}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const providerDefinition = createTestProviderDefinition({
|
||||||
|
fetchModels,
|
||||||
|
fetchModes: vi.fn(async () => []),
|
||||||
|
});
|
||||||
|
const providerSnapshotManager = new ProviderSnapshotManager(
|
||||||
|
{ codex: providerDefinition },
|
||||||
|
pino({ level: "silent" }),
|
||||||
|
);
|
||||||
|
const session = createSessionForTest({ messages, providerSnapshotManager });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "get_providers_snapshot_request",
|
||||||
|
cwd: workspaceCwd,
|
||||||
|
requestId: "snapshot-workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchModels).toHaveBeenCalledWith({
|
||||||
|
cwd: workspaceCwd,
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
providerSnapshotManager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes legacy model and mode list requests without cwd to home", async () => {
|
test("normalizes legacy model and mode list requests without cwd to home", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
const session = createSessionForTest({ messages });
|
const session = createSessionForTest({ messages });
|
||||||
|
|||||||
@@ -1237,10 +1237,11 @@ export class Session {
|
|||||||
const visibleEntries = entries.filter((entry) =>
|
const visibleEntries = entries.filter((entry) =>
|
||||||
this.isProviderVisibleToClient(entry.provider),
|
this.isProviderVisibleToClient(entry.provider),
|
||||||
);
|
);
|
||||||
|
const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd;
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "providers_snapshot_update",
|
type: "providers_snapshot_update",
|
||||||
payload: {
|
payload: {
|
||||||
cwd,
|
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
|
||||||
entries: visibleEntries,
|
entries: visibleEntries,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user