Fix provider models per workspace (#1167)

* Scope provider snapshots by workspace

* Fix provider snapshot cwd test on Windows
This commit is contained in:
Mohamed Boudra
2026-05-25 17:12:15 +08:00
committed by GitHub
parent 6b35c0a640
commit f137705f1e
15 changed files with 336 additions and 91 deletions
@@ -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,
});