fix(web): keep fresh agent hydration snapshots

This commit is contained in:
Carlos Florencio
2026-09-14 10:48:45 +01:00
parent 929c4c3cfe
commit 5d620928bd
5 changed files with 181 additions and 25 deletions
@@ -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);
});
});
+10 -4
View File
@@ -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]);
});
});
+6
View File
@@ -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", () => {
+35 -21
View File
@@ -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 },