import { createElement, useEffect, useState } from "react"; import { act, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useGatewayConfigSyncController } from "@/features/agents/operations/useGatewayConfigSyncController"; import type { GatewayModelChoice, GatewayModelPolicySnapshot } from "@/lib/gateway/models"; import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; vi.mock("@/lib/gateway/agentConfig", async () => { const actual = await vi.importActual( "@/lib/gateway/agentConfig" ); return { ...actual, updateGatewayAgentOverrides: vi.fn(async () => undefined), }; }); type ProbeValue = { gatewayConfigSnapshot: GatewayModelPolicySnapshot | null; gatewayModels: GatewayModelChoice[]; gatewayModelsError: string | null; refreshGatewayConfigSnapshot: () => Promise; }; type RenderControllerContext = { getValue: () => ProbeValue; rerenderWith: ( overrides: Partial<{ status: "disconnected" | "connecting" | "connected"; settingsRouteActive: boolean; inspectSidebarAgentId: string | null; logError: (message: string, err: unknown) => void; }> ) => void; call: ReturnType; enqueueConfigMutation: ReturnType; loadAgents: ReturnType; logError: (message: string, err: unknown) => void; }; const countMethodCalls = (callMock: ReturnType, method: string) => { return callMock.mock.calls.filter(([calledMethod]) => calledMethod === method).length; }; type RenderControllerParams = { status: "disconnected" | "connecting" | "connected"; settingsRouteActive: boolean; inspectSidebarAgentId: string | null; initialGatewayConfigSnapshot?: GatewayModelPolicySnapshot | null; isDisconnectLikeError: (err: unknown) => boolean; logError: (message: string, err: unknown) => void; }; const renderController = ( overrides?: Partial< RenderControllerParams & { call: ReturnType; enqueueConfigMutation: ReturnType; loadAgents: ReturnType; } > ): RenderControllerContext => { const call = overrides?.call ?? vi.fn(async (method: string) => { if (method === "config.get") { return { config: {} }; } if (method === "models.list") { return { models: [] }; } throw new Error(`Unhandled method: ${method}`); }); const enqueueConfigMutation = overrides?.enqueueConfigMutation ?? vi.fn(async ({ run }: { run: () => Promise }) => { await run(); }); const loadAgents = overrides?.loadAgents ?? vi.fn(async () => undefined); const logError = (overrides?.logError ?? vi.fn()) as (message: string, err: unknown) => void; let currentParams: RenderControllerParams = { status: "connected" as const, settingsRouteActive: false, inspectSidebarAgentId: null as string | null, isDisconnectLikeError: overrides?.isDisconnectLikeError ?? (() => false), logError, ...overrides, }; const valueRef: { current: ProbeValue | null } = { current: null }; const Probe = ({ params, onValue, }: { params: typeof currentParams; onValue: (value: ProbeValue) => void; }) => { const [client] = useState(() => ({ call })); const [gatewayConfigSnapshot, setGatewayConfigSnapshot] = useState( params.initialGatewayConfigSnapshot ?? null ); const [gatewayModels, setGatewayModels] = useState([]); const [gatewayModelsError, setGatewayModelsError] = useState(null); const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({ client: client as unknown as GatewayClient, status: params.status, settingsRouteActive: params.settingsRouteActive, inspectSidebarAgentId: params.inspectSidebarAgentId, gatewayConfigSnapshot, setGatewayConfigSnapshot, setGatewayModels, setGatewayModelsError, enqueueConfigMutation: enqueueConfigMutation as (params: { kind: "repair-sandbox-tool-allowlist"; label: string; run: () => Promise; }) => Promise, loadAgents: loadAgents as () => Promise, isDisconnectLikeError: params.isDisconnectLikeError, logError: params.logError, }); useEffect(() => { onValue({ gatewayConfigSnapshot, gatewayModels, gatewayModelsError, refreshGatewayConfigSnapshot, }); }, [gatewayConfigSnapshot, gatewayModels, gatewayModelsError, onValue, refreshGatewayConfigSnapshot]); return createElement("div", { "data-testid": "probe" }, "ok"); }; const rendered = render( createElement(Probe, { params: currentParams, onValue: (value) => { valueRef.current = value; }, }) ); return { getValue: () => { if (!valueRef.current) throw new Error("controller value unavailable"); return valueRef.current; }, rerenderWith: (nextOverrides) => { currentParams = { ...currentParams, ...nextOverrides, }; rendered.rerender( createElement(Probe, { params: currentParams, onValue: (value) => { valueRef.current = value; }, }) ); }, call, enqueueConfigMutation, loadAgents, logError, }; }; describe("useGatewayConfigSyncController", () => { const mockedUpdateGatewayAgentOverrides = vi.mocked(updateGatewayAgentOverrides); beforeEach(() => { mockedUpdateGatewayAgentOverrides.mockReset(); mockedUpdateGatewayAgentOverrides.mockResolvedValue(); }); it("clears models, model error, and snapshot when disconnected", async () => { const call = vi.fn(async (method: string) => { if (method === "config.get") { return { config: { agents: { list: [] } } }; } if (method === "models.list") { return { models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }] }; } throw new Error(`Unhandled method: ${method}`); }); const ctx = renderController({ call, status: "connected" }); await waitFor(() => { expect(ctx.getValue().gatewayModels).toEqual([ { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, ]); }); ctx.rerenderWith({ status: "disconnected" }); await waitFor(() => { expect(ctx.getValue().gatewayModels).toEqual([]); expect(ctx.getValue().gatewayModelsError).toBeNull(); expect(ctx.getValue().gatewayConfigSnapshot).toBeNull(); }); }); it("still loads models when config.get fails", async () => { const call = vi.fn(async (method: string) => { if (method === "config.get") { throw new Error("config failed"); } if (method === "models.list") { return { models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }], }; } throw new Error(`Unhandled method: ${method}`); }); const logError = vi.fn(); const ctx = renderController({ call, logError }); await waitFor(() => { expect(ctx.getValue().gatewayModels).toEqual([ { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, ]); }); expect(countMethodCalls(call, "models.list")).toBe(1); expect(logError).toHaveBeenCalledWith("Failed to load gateway config.", expect.any(Error)); }); it("captures model loading errors and clears models", async () => { const call = vi.fn(async (method: string) => { if (method === "config.get") { return { config: { agents: { list: [] } } }; } if (method === "models.list") { throw new Error("models unavailable"); } throw new Error(`Unhandled method: ${method}`); }); const logError = vi.fn(); const ctx = renderController({ call, logError }); await waitFor(() => { expect(ctx.getValue().gatewayModels).toEqual([]); expect(ctx.getValue().gatewayModelsError).toBe("models unavailable"); }); expect(logError).toHaveBeenCalledWith("Failed to load gateway models.", expect.any(Error)); }); it("runs settings-route refresh only when inspect agent id is present", async () => { const call = vi.fn(async (method: string) => { if (method === "config.get") { return { config: { agents: { list: [] } } }; } if (method === "models.list") { return { models: [] }; } throw new Error(`Unhandled method: ${method}`); }); renderController({ call, status: "connected", settingsRouteActive: true, inspectSidebarAgentId: null, }); await waitFor(() => { expect(countMethodCalls(call, "config.get")).toBe(1); expect(countMethodCalls(call, "models.list")).toBe(1); }); const callEligible = vi.fn(async (method: string) => { if (method === "config.get") { return { config: { agents: { list: [] } } }; } if (method === "models.list") { return { models: [] }; } throw new Error(`Unhandled method: ${method}`); }); renderController({ call: callEligible, status: "connected", settingsRouteActive: true, inspectSidebarAgentId: "agent-1", }); await waitFor(() => { expect(countMethodCalls(callEligible, "config.get")).toBeGreaterThanOrEqual(2); expect(countMethodCalls(callEligible, "models.list")).toBe(1); }); }); it("enqueues sandbox repair once for eligible agents", async () => { const brokenSnapshot = { config: { agents: { list: [ { id: "agent-broken", sandbox: { mode: "all" }, tools: { sandbox: { tools: { allow: [], }, }, }, }, ], }, }, } as unknown as GatewayModelPolicySnapshot; const call = vi.fn(async (method: string) => { if (method === "config.get") { return brokenSnapshot; } if (method === "models.list") { return { models: [] }; } throw new Error(`Unhandled method: ${method}`); }); const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise }) => { await run(); }); const loadAgents = vi.fn(async () => undefined); const ctx = renderController({ call, enqueueConfigMutation, loadAgents, initialGatewayConfigSnapshot: brokenSnapshot, }); await waitFor(() => { expect(enqueueConfigMutation).toHaveBeenCalledTimes(1); expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1); expect(loadAgents).toHaveBeenCalledTimes(1); }); ctx.rerenderWith({ status: "connected" }); await act(async () => { await Promise.resolve(); }); expect(enqueueConfigMutation).toHaveBeenCalledTimes(1); expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1); }); it("returns null when refresh is called while disconnected", async () => { const call = vi.fn(async () => { throw new Error("should not call gateway when disconnected"); }); const ctx = renderController({ call, status: "disconnected" }); const result = await ctx.getValue().refreshGatewayConfigSnapshot(); expect(result).toBeNull(); expect(call).not.toHaveBeenCalled(); }); });