fix: provider-scoped snapshot refresh with self-healing invalidation
This commit is contained in:
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
providersSnapshotQueryKey,
|
||||
@@ -14,11 +15,31 @@ import {
|
||||
useProvidersSnapshot,
|
||||
} from "./use-providers-snapshot";
|
||||
|
||||
const { mockClient, mockRuntime } = vi.hoisted(() => {
|
||||
type ProviderSnapshotUpdateMessage = {
|
||||
type: "providers_snapshot_update";
|
||||
payload: {
|
||||
cwd: string;
|
||||
entries: ProviderSnapshotEntry[];
|
||||
generatedAt: string;
|
||||
};
|
||||
};
|
||||
type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void;
|
||||
type ProvidersSnapshot = {
|
||||
entries: ProviderSnapshotEntry[];
|
||||
generatedAt: string;
|
||||
requestId: string;
|
||||
};
|
||||
type HookResult = ReturnType<typeof renderProvidersSnapshotHook>["result"];
|
||||
|
||||
const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => {
|
||||
const snapshotUpdateListeners: ProviderSnapshotUpdateListener[] = [];
|
||||
const mockClient = {
|
||||
getProvidersSnapshot: vi.fn(),
|
||||
refreshProvidersSnapshot: vi.fn(),
|
||||
on: vi.fn(() => () => {}),
|
||||
on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => {
|
||||
snapshotUpdateListeners.push(listener);
|
||||
return () => {};
|
||||
}),
|
||||
};
|
||||
return {
|
||||
mockClient,
|
||||
@@ -26,6 +47,7 @@ const { mockClient, mockRuntime } = vi.hoisted(() => {
|
||||
client: mockClient,
|
||||
isConnected: true,
|
||||
},
|
||||
snapshotUpdateListeners,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,11 +88,70 @@ function renderProvidersSnapshotHook(cwd?: string | null) {
|
||||
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
|
||||
}
|
||||
|
||||
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
|
||||
|
||||
function providersSnapshot(entries: ProviderSnapshotEntry[]): ProvidersSnapshot {
|
||||
return {
|
||||
entries,
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
requestId: "snapshot",
|
||||
};
|
||||
}
|
||||
|
||||
function codexEntry(
|
||||
status: ProviderSnapshotEntry["status"],
|
||||
models?: ProviderSnapshotEntry["models"],
|
||||
): ProviderSnapshotEntry {
|
||||
return {
|
||||
provider: "codex",
|
||||
status,
|
||||
...(models ? { models } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSnapshotReads(count: number): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(count);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSnapshotEntries(
|
||||
result: HookResult,
|
||||
entries: ProviderSnapshotEntry[],
|
||||
): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toEqual(entries);
|
||||
});
|
||||
}
|
||||
|
||||
async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Promise<void> {
|
||||
const listener = snapshotUpdateListeners.at(-1);
|
||||
expect(listener).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
listener?.({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
entries,
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openSelectorForSelectedProvider(result: HookResult): Promise<void> {
|
||||
await act(async () => {
|
||||
result.current.refetchIfStale("codex");
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
snapshotUpdateListeners.length = 0;
|
||||
});
|
||||
|
||||
describe("providers snapshot hook cache scope", () => {
|
||||
@@ -95,11 +176,7 @@ describe("providers snapshot hook cache scope", () => {
|
||||
|
||||
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue({
|
||||
entries: [],
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
requestId: "settings",
|
||||
});
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
acknowledged: true,
|
||||
requestId: "settings-refresh",
|
||||
@@ -121,11 +198,7 @@ describe("providers snapshot hook cache scope", () => {
|
||||
|
||||
it("sends cwd for workspace snapshot loads and refreshes", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue({
|
||||
entries: [],
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
requestId: "workspace",
|
||||
});
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||
acknowledged: true,
|
||||
requestId: "workspace-refresh",
|
||||
@@ -147,4 +220,58 @@ describe("providers snapshot hook cache scope", () => {
|
||||
});
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("refetches loading snapshot updates through the read path but ignores empty updates", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot
|
||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [])]))
|
||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||
|
||||
renderProvidersSnapshotHook("/repo");
|
||||
|
||||
await waitForSnapshotReads(1);
|
||||
await emitProvidersSnapshotUpdate([]);
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
|
||||
await waitForSnapshotReads(2);
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "missing", entries: [] },
|
||||
{ name: "loading", entries: [codexEntry("loading")] },
|
||||
])("ensures a selected provider snapshot on selector open when it is $name", async ({
|
||||
entries,
|
||||
}) => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot
|
||||
.mockResolvedValueOnce(providersSnapshot(entries))
|
||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook("/repo");
|
||||
|
||||
await waitForSnapshotEntries(result, entries);
|
||||
await openSelectorForSelectedProvider(result);
|
||||
await waitForSnapshotReads(2);
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ensure a selected provider snapshot on selector open when the provider is ready with no models", async () => {
|
||||
enableProvidersSnapshot();
|
||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
|
||||
|
||||
const { result } = renderProvidersSnapshotHook("/repo");
|
||||
|
||||
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
|
||||
await openSelectorForSelectedProvider(result);
|
||||
|
||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user