Fix provider models per workspace (#1167)
* Scope provider snapshots by workspace * Fix provider snapshot cwd test on Windows
This commit is contained in:
@@ -1645,7 +1645,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
|
||||
|
||||
@@ -393,6 +393,7 @@ export function ImportSessionSheet({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||
cwd,
|
||||
enabled: visible,
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,10 @@ function buildChatDraftComposerArgs({
|
||||
}) {
|
||||
return {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
||||
initialValues:
|
||||
workspaceDirectory || sourceDirectory
|
||||
? { workingDir: workspaceDirectory || sourceDirectory }
|
||||
: undefined,
|
||||
isVisible: pendingWorkspaceSetup !== null,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
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,
|
||||
refresh: refreshSnapshot,
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
} = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir });
|
||||
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(
|
||||
|
||||
@@ -14,7 +14,7 @@ import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers
|
||||
interface ProviderSnapshotUpdateMessage {
|
||||
type: "providers_snapshot_update";
|
||||
payload: {
|
||||
cwd: string;
|
||||
cwd?: string;
|
||||
entries: ProviderSnapshotEntry[];
|
||||
generatedAt: string;
|
||||
};
|
||||
@@ -76,12 +76,12 @@ function enableProvidersSnapshot(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderProvidersSnapshotHook() {
|
||||
function renderProvidersSnapshotHook(options: { cwd?: string | null } = {}) {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
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;
|
||||
@@ -123,7 +123,7 @@ async function waitForSnapshotEntries(
|
||||
|
||||
async function emitProvidersSnapshotUpdate(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
cwd = "/repo",
|
||||
cwd?: string,
|
||||
): Promise<void> {
|
||||
const listener = snapshotUpdateListeners.at(-1);
|
||||
expect(listener).toBeDefined();
|
||||
@@ -132,7 +132,7 @@ async function emitProvidersSnapshotUpdate(
|
||||
listener?.({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
cwd,
|
||||
...(cwd ? { cwd } : {}),
|
||||
entries,
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
},
|
||||
@@ -155,8 +155,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("providers snapshot hook cache scope", () => {
|
||||
it("uses a global query key without cwd", () => {
|
||||
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId]);
|
||||
it("uses separate query keys for home and workspace scopes", () => {
|
||||
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 () => {
|
||||
@@ -181,7 +187,7 @@ describe("providers snapshot hook cache scope", () => {
|
||||
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();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
@@ -189,29 +195,34 @@ describe("providers snapshot hook cache scope", () => {
|
||||
requestId: "workspace-refresh",
|
||||
});
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo-a" });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh(["codex"]);
|
||||
});
|
||||
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
||||
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();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook();
|
||||
const { result } = renderProvidersSnapshotHook({ cwd: "/repo-a" });
|
||||
|
||||
await waitForSnapshotEntries(result, []);
|
||||
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])]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,15 @@ import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
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) {
|
||||
return ["providersSnapshot", serverId] as const;
|
||||
}
|
||||
export { providersSnapshotQueryKey, providersSnapshotQueryRoot };
|
||||
|
||||
interface UseProvidersSnapshotResult {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
@@ -23,6 +28,7 @@ interface UseProvidersSnapshotResult {
|
||||
|
||||
interface UseProvidersSnapshotOptions {
|
||||
enabled?: boolean;
|
||||
cwd?: string | null;
|
||||
}
|
||||
|
||||
export function useProvidersSnapshot(
|
||||
@@ -33,11 +39,14 @@ export function useProvidersSnapshot(
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const enabled = options.enabled ?? true;
|
||||
const cwd = normalizeProvidersSnapshotCwd(options.cwd);
|
||||
const supportsSnapshot = useSessionStore(
|
||||
(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({
|
||||
queryKey,
|
||||
@@ -47,7 +56,7 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot({});
|
||||
return client.getProvidersSnapshot(requestOptions);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +65,7 @@ export function useProvidersSnapshot(
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.refreshProvidersSnapshot(providers ? { providers } : {});
|
||||
await client.refreshProvidersSnapshot(providersSnapshotRequestOptions({ cwd, providers }));
|
||||
},
|
||||
});
|
||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||
@@ -70,7 +79,8 @@ export function useProvidersSnapshot(
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, {
|
||||
const updateQueryKey = providersSnapshotQueryKey(serverId, message.payload.cwd);
|
||||
queryClient.setQueryData(updateQueryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
@@ -78,13 +88,13 @@ export function useProvidersSnapshot(
|
||||
const shouldRefetch = message.payload.entries.some((entry) => entry.status === "loading");
|
||||
if (shouldRefetch) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
queryKey: updateQueryKey,
|
||||
exact: true,
|
||||
refetchType: "active",
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [client, enabled, isConnected, queryClient, queryKey, serverId, supportsSnapshot]);
|
||||
}, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (providers?: AgentProvider[]) => {
|
||||
@@ -92,10 +102,13 @@ export function useProvidersSnapshot(
|
||||
return;
|
||||
}
|
||||
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);
|
||||
},
|
||||
[client, queryClient, queryKey, refreshSnapshot],
|
||||
[client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions],
|
||||
);
|
||||
|
||||
const refetchIfStale = useCallback(
|
||||
@@ -131,11 +144,17 @@ export function useProvidersSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
|
||||
const queryKey = providersSnapshotQueryKey(serverId);
|
||||
export function prefetchProvidersSnapshot(
|
||||
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({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => client.getProvidersSnapshot({}),
|
||||
queryFn: () => client.getProvidersSnapshot(requestOptions),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -381,7 +381,10 @@ function buildComposerConfig(input: {
|
||||
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
|
||||
return {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
||||
initialValues:
|
||||
workspaceDirectory || sourceDirectory
|
||||
? { workingDir: workspaceDirectory || sourceDirectory }
|
||||
: undefined,
|
||||
isVisible: true,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
|
||||
|
||||
@@ -1502,8 +1502,9 @@ function WorkspaceScreenContent({
|
||||
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, {
|
||||
cwd: workspaceDirectory,
|
||||
enabled: isRouteFocused,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user