diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts index 829e19d503..e31f6cb23c 100644 --- a/apps/web/lib/state/hydration/hydrator.test.ts +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -451,6 +451,7 @@ describe("hydrateState — agent profile revisions", () => { version: 0, items: [], }, + settingsData: { agentsLoaded: true }, } as unknown as Partial); }); @@ -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) => { + 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); + }); + + expect(result.agentProfiles).toEqual({ version: 1, items: [] }); + expect(result.settingsAgents.items).toEqual([]); + expect(result.settingsData.agentsLoaded).toBe(true); }); }); diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index fb51b50c2b..6f5bf796c4 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -166,9 +166,8 @@ function hydrateSettings(draft: Draft, 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, 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( diff --git a/apps/web/src/settings-routes.bootstrap.test.ts b/apps/web/src/settings-routes.bootstrap.test.ts new file mode 100644 index 0000000000..4b149fab83 --- /dev/null +++ b/apps/web/src/settings-routes.bootstrap.test.ts @@ -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()), + 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()), + 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() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + 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]); + }); +}); diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index 0394f7f86e..b327b5d2a2 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -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", () => { diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 9b9eafbe50..38f65ea008 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -120,6 +120,7 @@ type SettingsInitialStateData = { availableAgents: Awaited>["agents"]; availableTools: NonNullable>["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 { - 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 { + // 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 },