fix(web): keep fresh agent hydration snapshots
This commit is contained in:
@@ -451,6 +451,7 @@ describe("hydrateState — agent profile revisions", () => {
|
||||
version: 0,
|
||||
items: [],
|
||||
},
|
||||
settingsData: { agentsLoaded: true },
|
||||
} as unknown as Partial<AppState>);
|
||||
});
|
||||
|
||||
@@ -459,6 +460,44 @@ describe("hydrateState — agent profile revisions", () => {
|
||||
items: [{ id: "live-profile" }],
|
||||
});
|
||||
expect(result.settingsAgents.items).toHaveLength(1);
|
||||
expect(result.settingsData.agentsLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it("applies a fresh snapshot captured at the current generation", () => {
|
||||
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
||||
draft.agentProfiles = {
|
||||
version: 1,
|
||||
items: [
|
||||
{
|
||||
id: "stale-profile",
|
||||
label: "Stale profile",
|
||||
agent_id: "agent-1",
|
||||
agent_name: "Agent",
|
||||
cli_passthrough: false,
|
||||
inference_capable: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
draft.settingsAgents.items = [
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Agent",
|
||||
profiles: [{ id: "stale-profile" }],
|
||||
} as never,
|
||||
];
|
||||
hydrateState(draft, {
|
||||
settingsAgents: { items: [] },
|
||||
agentProfiles: {
|
||||
version: 1,
|
||||
items: [],
|
||||
},
|
||||
settingsData: { agentsLoaded: true },
|
||||
} as unknown as Partial<AppState>);
|
||||
});
|
||||
|
||||
expect(result.agentProfiles).toEqual({ version: 1, items: [] });
|
||||
expect(result.settingsAgents.items).toEqual([]);
|
||||
expect(result.settingsData.agentsLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -166,9 +166,8 @@ function hydrateSettings(draft: Draft<AppState>, state: HydrationState): void {
|
||||
deepMerge(draft.settingsAgents, state.settingsAgents);
|
||||
}
|
||||
if (state.agentProfiles) {
|
||||
// Route bootstrap snapshots start at version zero. Preserve a newer
|
||||
// profile mutation delivered over WebSocket while that snapshot was in
|
||||
// flight; otherwise the stale response can erase the live option.
|
||||
// Preserve a newer profile mutation delivered over WebSocket while this
|
||||
// snapshot was in flight; otherwise the stale response can erase it.
|
||||
if (!preserveLiveAgentProfiles) {
|
||||
deepMerge(draft.agentProfiles, state.agentProfiles);
|
||||
}
|
||||
@@ -176,7 +175,14 @@ function hydrateSettings(draft: Draft<AppState>, state: HydrationState): void {
|
||||
mergeWithLoading(draft.editors, state.editors);
|
||||
mergeWithLoading(draft.prompts, state.prompts);
|
||||
mergeWithLoading(draft.notificationProviders, state.notificationProviders);
|
||||
if (state.settingsData) deepMerge(draft.settingsData, state.settingsData);
|
||||
if (state.settingsData) {
|
||||
// A rejected agent snapshot is incomplete. Leave the loading marker false
|
||||
// so the settings data hook can retry the complete list.
|
||||
const settingsData = preserveLiveAgentProfiles
|
||||
? { ...state.settingsData, agentsLoaded: false }
|
||||
: state.settingsData;
|
||||
deepMerge(draft.settingsData, settingsData);
|
||||
}
|
||||
if (state.sleepInhibition) deepMerge(draft.sleepInhibition, state.sleepInhibition);
|
||||
if (state.agentProfileRecentUse) {
|
||||
draft.agentProfileRecentUse = mergeAgentProfileRecentUseState(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Agent, ListAgentsResponse, ListWorkspacesResponse } from "@/lib/types/http";
|
||||
import { loadSettingsInitialState } from "./settings-routes";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchUserSettings: vi.fn(),
|
||||
listAgentDiscovery: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
listAvailableAgents: vi.fn(),
|
||||
listExecutors: vi.fn(),
|
||||
listWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/api/domains/settings-api")>()),
|
||||
fetchUserSettings: mocks.fetchUserSettings,
|
||||
listAgentDiscovery: mocks.listAgentDiscovery,
|
||||
listAgents: mocks.listAgents,
|
||||
listAvailableAgents: mocks.listAvailableAgents,
|
||||
listExecutors: mocks.listExecutors,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/workspace-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/api/domains/workspace-api")>()),
|
||||
listWorkspaces: mocks.listWorkspaces,
|
||||
}));
|
||||
|
||||
const TEST_AGENT = {
|
||||
id: "agent-1",
|
||||
name: "Agent",
|
||||
supports_mcp: false,
|
||||
profiles: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
} as unknown as Agent;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function agentResponse(agents: Agent[]): ListAgentsResponse {
|
||||
return { agents, total: agents.length };
|
||||
}
|
||||
|
||||
function emptyWorkspaceResponse(): ListWorkspacesResponse {
|
||||
return { workspaces: [], total: 0 };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.fetchUserSettings.mockRejectedValue(new Error("settings unavailable"));
|
||||
mocks.listAgentDiscovery.mockResolvedValue({ agents: [], total: 0 });
|
||||
mocks.listAvailableAgents.mockResolvedValue({ agents: [], tools: [], total: 0 });
|
||||
mocks.listExecutors.mockResolvedValue({ executors: [], total: 0 });
|
||||
mocks.listWorkspaces.mockResolvedValue(emptyWorkspaceResponse());
|
||||
mocks.listAgents.mockResolvedValue(agentResponse([]));
|
||||
});
|
||||
|
||||
describe("loadSettingsInitialState", () => {
|
||||
it("retries the full snapshot when a profile event races the request", async () => {
|
||||
const firstAgentsRead = deferred<ListAgentsResponse>();
|
||||
mocks.listAgents
|
||||
.mockImplementationOnce(() => firstAgentsRead.promise)
|
||||
.mockResolvedValueOnce(agentResponse([TEST_AGENT]));
|
||||
let version = 0;
|
||||
|
||||
const initialStatePromise = loadSettingsInitialState(() => version);
|
||||
version = 1;
|
||||
firstAgentsRead.resolve(agentResponse([]));
|
||||
|
||||
const initialState = await initialStatePromise;
|
||||
|
||||
expect(mocks.listAgents).toHaveBeenCalledTimes(2);
|
||||
expect(initialState.agentProfiles?.version).toBe(1);
|
||||
expect(initialState.settingsAgents?.items).toEqual([TEST_AGENT]);
|
||||
});
|
||||
|
||||
it("uses the current generation when the request starts after a profile event", async () => {
|
||||
mocks.listAgents.mockResolvedValue(agentResponse([TEST_AGENT]));
|
||||
|
||||
const initialState = await loadSettingsInitialState(() => 2);
|
||||
|
||||
expect(initialState.agentProfiles?.version).toBe(2);
|
||||
expect(initialState.settingsAgents?.items).toEqual([TEST_AGENT]);
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,12 @@ describe("buildSettingsInitialStateForRoute", () => {
|
||||
|
||||
expect(state.system).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stamps the agent snapshot with the request generation", () => {
|
||||
const state = buildState({ agentProfilesVersion: 4 });
|
||||
|
||||
expect(state.agentProfiles?.version).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message queue settings route", () => {
|
||||
|
||||
@@ -120,6 +120,7 @@ type SettingsInitialStateData = {
|
||||
availableAgents: Awaited<ReturnType<typeof listAvailableAgents>>["agents"];
|
||||
availableTools: NonNullable<Awaited<ReturnType<typeof listAvailableAgents>>["tools"]>;
|
||||
userSettingsResponse: UserSettingsResponse | null;
|
||||
agentProfilesVersion?: number;
|
||||
};
|
||||
|
||||
const licenseEntries = licenses as LicenseEntry[];
|
||||
@@ -564,7 +565,9 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
|
||||
let cancelled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
const initialState = await loadSettingsInitialState();
|
||||
const initialState = await loadSettingsInitialState(
|
||||
() => store.getState().agentProfiles.version,
|
||||
);
|
||||
if (!cancelled && Object.keys(initialState).length > 0) {
|
||||
store.getState().hydrate(initialState);
|
||||
}
|
||||
@@ -580,26 +583,36 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSettingsInitialState(): Promise<HydrationState> {
|
||||
const [workspaces, executors, agents, discovery, available, userSettingsResponse] =
|
||||
await Promise.all([
|
||||
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
|
||||
listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })),
|
||||
listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })),
|
||||
listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })),
|
||||
listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })),
|
||||
fetchUserSettings({ cache: "no-store" }).catch(() => null),
|
||||
]);
|
||||
export async function loadSettingsInitialState(
|
||||
getAgentProfilesVersion: () => number,
|
||||
): Promise<HydrationState> {
|
||||
// A profile event can arrive while any of these requests are in flight. Do
|
||||
// not publish a partial snapshot as loaded; repeat the complete read until
|
||||
// it was captured at one stable local generation.
|
||||
for (;;) {
|
||||
const agentProfilesVersion = getAgentProfilesVersion();
|
||||
const [workspaces, executors, agents, discovery, available, userSettingsResponse] =
|
||||
await Promise.all([
|
||||
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
|
||||
listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })),
|
||||
listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })),
|
||||
listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })),
|
||||
listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })),
|
||||
fetchUserSettings({ cache: "no-store" }).catch(() => null),
|
||||
]);
|
||||
|
||||
return buildSettingsInitialStateForRoute({
|
||||
workspaces: workspaces.workspaces,
|
||||
executors: executors.executors,
|
||||
agents: agents.agents,
|
||||
discoveryAgents: discovery.agents,
|
||||
availableAgents: available.agents,
|
||||
availableTools: available.tools ?? [],
|
||||
userSettingsResponse,
|
||||
});
|
||||
const initialState = buildSettingsInitialStateForRoute({
|
||||
workspaces: workspaces.workspaces,
|
||||
executors: executors.executors,
|
||||
agents: agents.agents,
|
||||
discoveryAgents: discovery.agents,
|
||||
availableAgents: available.agents,
|
||||
availableTools: available.tools ?? [],
|
||||
userSettingsResponse,
|
||||
agentProfilesVersion,
|
||||
});
|
||||
if (getAgentProfilesVersion() === agentProfilesVersion) return initialState;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSettingsInitialStateForRoute({
|
||||
@@ -610,6 +623,7 @@ export function buildSettingsInitialStateForRoute({
|
||||
availableAgents,
|
||||
availableTools,
|
||||
userSettingsResponse,
|
||||
agentProfilesVersion = 0,
|
||||
}: SettingsInitialStateData): HydrationState {
|
||||
const workspaceItems = workspaces.map(mapWorkspaceItem);
|
||||
promoteLegacyWorkspaceSelection(workspaceItems);
|
||||
@@ -627,7 +641,7 @@ export function buildSettingsInitialStateForRoute({
|
||||
items: agents.flatMap((agent) =>
|
||||
agent.profiles.map((profile) => toAgentProfileOption(agent, profile)),
|
||||
),
|
||||
version: 0,
|
||||
version: agentProfilesVersion,
|
||||
},
|
||||
settingsAgents: { items: agents },
|
||||
agentDiscovery: { items: discoveryAgents, loading: false, loaded: true },
|
||||
|
||||
Reference in New Issue
Block a user