diff --git a/docs/providers.md b/docs/providers.md index 1d632deed..3a7a0cb44 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -36,11 +36,15 @@ Draft metadata lookups should avoid creating provider sessions when the upstream ## 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. --- diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 9dcc01202..a4a4dde80 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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), diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index b82959199..09b0bf21e 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -393,6 +393,7 @@ export function ImportSessionSheet({ const queryClient = useQueryClient(); const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { + cwd, enabled: visible, }); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index f6a54a3df..5797881a8 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -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, diff --git a/packages/app/src/hooks/providers-snapshot-query.test.ts b/packages/app/src/hooks/providers-snapshot-query.test.ts new file mode 100644 index 000000000..d3cceb0dc --- /dev/null +++ b/packages/app/src/hooks/providers-snapshot-query.test.ts @@ -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"], + }); + }); +}); diff --git a/packages/app/src/hooks/providers-snapshot-query.ts b/packages/app/src/hooks/providers-snapshot-query.ts new file mode 100644 index 000000000..2874bf042 --- /dev/null +++ b/packages/app/src/hooks/providers-snapshot-query.ts @@ -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; +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index c99a34d7b..46b729369 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -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( diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index a53993c7d..175ca3545 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -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 { 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])]); }); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 8c04f3927..2af8d6607 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -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), }); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index eb83ee9a6..01ea165b4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index be2681780..75787ccc6 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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, }); 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 6bda87ff2..92d73d8f0 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -237,7 +237,7 @@ describe("ProviderSnapshotManager", () => { 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 ?? [], "codex")?.status).toBe("loading"); @@ -542,19 +542,19 @@ describe("ProviderSnapshotManager", () => { await Promise.resolve(); expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).toHaveBeenCalledWith(homedir(), false); - expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true); + expect(fetchModels).toHaveBeenCalledWith(projectCwd, false); + expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true); loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]); await warmUpPromise; expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true); + expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true); 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 .fn<(cwd: string, force: boolean) => Promise>() .mockImplementation(async (_cwd, force) => [ @@ -585,22 +585,31 @@ describe("ProviderSnapshotManager", () => { await manager.refreshSettingsSnapshot({ providers: ["codex"] }); expect(fetchModels.mock.calls).toEqual([ - [homedir(), false], + [projectACwd, false], + [projectBCwd, false], [homedir(), true], ]); - const projectASnapshot = manager.getSnapshot(projectACwd); - expect(getProviderEntry(projectASnapshot, "codex")).toMatchObject({ + const homeSnapshot = manager.getSnapshot(); + expect(getProviderEntry(homeSnapshot, "codex")).toMatchObject({ provider: "codex", status: "ready", 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"); 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 .fn<(cwd: string, force: boolean) => Promise>() .mockImplementation(async (_cwd, force) => [ @@ -624,19 +633,59 @@ describe("ProviderSnapshotManager", () => { await manager.refreshSettingsSnapshot({ providers: ["codex"] }); expect(fetchModels.mock.calls).toEqual([ - [homedir(), false], + [projectCwd, false], [homedir(), true], ]); await vi.waitFor(() => { expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "refreshed", + "initial", ); }); manager.destroy(); }); + test("settings refresh prevents stale in-flight workspace loads from repopulating the cache", async () => { + const workspaceModels = deferred(); + const fetchModels = vi + .fn<(cwd: string, force: boolean) => Promise>() + .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 () => { const fetchModels = deferred(); const { registry } = createRegistry([ @@ -748,7 +797,7 @@ describe("ProviderSnapshotManager", () => { expect(unavailableIsAvailable).toHaveBeenCalledTimes(1); expect(errorFetchModels).toHaveBeenCalledTimes(1); - await manager.refreshSettingsSnapshot({ providers: ["codex", "claude"] }); + await manager.refreshSnapshotForCwd({ cwd: projectCwd, providers: ["codex", "claude"] }); await vi.waitFor(() => { expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); @@ -810,7 +859,7 @@ describe("ProviderSnapshotManager", () => { expect(nextHandles.zai?.createClient).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({ provider: "zai", @@ -858,7 +907,7 @@ describe("ProviderSnapshotManager", () => { 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 { registry } = createRegistry([ createMockProvider({ @@ -880,12 +929,12 @@ describe("ProviderSnapshotManager", () => { }); expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe( - `model:${homedir()}`, + `model:${projectACwd}`, ); 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(); }); @@ -917,7 +966,7 @@ describe("ProviderSnapshotManager", () => { 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 { registry } = createRegistry([ createMockProvider({ @@ -934,15 +983,18 @@ describe("ProviderSnapshotManager", () => { manager.getSnapshot("relative-provider-test/.."); 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(); }); - 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 .fn<(cwd: string, force: boolean) => Promise>() .mockImplementation(async (_cwd, force) => [ @@ -960,17 +1012,18 @@ describe("ProviderSnapshotManager", () => { manager.getSnapshot(projectBCwd); await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledTimes(1); + expect(fetchModels).toHaveBeenCalledTimes(2); }); await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] }); expect(fetchModels.mock.calls).toEqual([ - [homedir(), false], - [homedir(), true], + [projectACwd, false], + [projectBCwd, false], + [projectACwd, true], ]); expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe( - "refreshed", + "initial", ); manager.destroy(); @@ -1013,7 +1066,7 @@ describe("ProviderSnapshotManager", () => { provider: "codex", }), ]), - homedir(), + projectCwd, ); manager.destroy(); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 3ac75868d..507170c41 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -44,8 +44,8 @@ export class ProviderSnapshotManager { this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; } - getSnapshot(_cwd?: string): ProviderSnapshotEntry[] { - const resolvedCwd = resolveGlobalSnapshotCwd(); + getSnapshot(cwd?: string): ProviderSnapshotEntry[] { + const resolvedCwd = resolveSnapshotCwd(cwd); const entries = this.snapshots.get(resolvedCwd); if (!entries) { const loadingEntries = this.resetSnapshotToLoading(resolvedCwd); @@ -54,9 +54,14 @@ export class ProviderSnapshotManager { } const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider)); if (missingProviders.length > 0) { + const loadingEntries = this.createLoadingEntries(); 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 loadingProviders = Array.from(entries.values()) @@ -69,9 +74,9 @@ export class ProviderSnapshotManager { } async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { - const snapshotCwd = resolveGlobalSnapshotCwd(); + const snapshotCwd = resolveSnapshotCwd(options.cwd); const providers = this.resolveRefreshProviders(options.providers); - this.resetSnapshotToLoading(snapshotCwd, providers); + this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); this.emitChange(snapshotCwd); await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds()); } @@ -79,17 +84,18 @@ export class ProviderSnapshotManager { async refreshSettingsSnapshot( options: Omit = {}, ): Promise { - const homeCwd = resolveGlobalSnapshotCwd(); + const homeCwd = resolveSnapshotCwd(); const providers = this.resolveRefreshProviders(options.providers); const providersToRefresh = providers ?? this.getProviderIds(); - this.resetSnapshotToLoading(homeCwd, providers); + this.clearCachedProviders(providers); + this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false }); this.emitChange(homeCwd); await this.refreshProviders(homeCwd, providersToRefresh); } async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { - const snapshotCwd = resolveGlobalSnapshotCwd(); + const snapshotCwd = resolveSnapshotCwd(options.cwd); const providers = this.resolveRefreshProviders(options.providers); if (options.providers && providers?.length === 0) { return; @@ -155,18 +161,6 @@ export class ProviderSnapshotManager { 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 { const existing = this.snapshots.get(cwd); const entries = new Map(); @@ -214,6 +208,47 @@ export class ProviderSnapshotManager { 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 { await Promise.allSettled( options.providers.map((provider) => this.loadProvider({ ...options, provider })), @@ -374,9 +409,11 @@ export class ProviderSnapshotManager { private resetSnapshotToLoading( cwdKey: string, providers?: AgentProvider[], + options: { preserveExisting?: boolean } = {}, ): Map { const snapshot = this.getOrCreateSnapshot(cwdKey); const loadingEntries = this.createLoadingEntries(); + const preserveExisting = options.preserveExisting ?? true; if (!providers) { snapshot.clear(); @@ -392,9 +429,13 @@ export class ProviderSnapshotManager { const existing = snapshot.get(provider); snapshot.set(provider, { ...loadingEntry, - models: existing?.models, - modes: existing?.modes, - fetchedAt: existing?.fetchedAt, + ...(preserveExisting + ? { + models: existing?.models, + modes: existing?.modes, + fetchedAt: existing?.fetchedAt, + } + : {}), }); } return snapshot; @@ -424,10 +465,6 @@ export function resolveSnapshotCwd(cwd?: string | null): string { return resolve(expanded); } -function resolveGlobalSnapshotCwd(): string { - return resolveSnapshotCwd(); -} - function entriesToArray( entries: Map, ): ProviderSnapshotEntry[] { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 82d7fd94d..e57a23f09 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -2,7 +2,7 @@ import { execSync } from "child_process"; import { EventEmitter } from "events"; import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { homedir, tmpdir } from "os"; -import { join } from "path"; +import { join, resolve as resolvePath } from "path"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -986,6 +986,42 @@ describe("session provider refresh cwd routing", () => { 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 () => { const messages: unknown[] = []; const session = createSessionForTest({ messages }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4bf3a8174..59064a4d5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1237,10 +1237,11 @@ export class Session { const visibleEntries = entries.filter((entry) => this.isProviderVisibleToClient(entry.provider), ); + const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd; this.emit({ type: "providers_snapshot_update", payload: { - cwd, + ...(snapshotCwd ? { cwd: snapshotCwd } : {}), entries: visibleEntries, generatedAt: new Date().toISOString(), },