From 654595f840d86eb622bad4b77415b0d6b27a4f94 Mon Sep 17 00:00:00 2001 From: Tom O'Rourke Date: Sat, 18 Apr 2026 17:20:59 +0100 Subject: [PATCH 1/2] fix(ui): poll agent list until all agents report deploymentReady MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agents page fetched the list once on mount and never refreshed. When a user navigated to the page while agents were still starting up, every card showed 'Agent not Ready' permanently — even after the underlying Kubernetes deployment became available. Add a 5-second polling interval in AgentsProvider that: - Only activates while at least one agent has deploymentReady=false - Updates the full agents list on each tick - Stops automatically the moment all agents are ready This means the UI converges to the correct state without user intervention (no manual refresh needed) and without wasting API calls once everything is healthy. --- ui/src/components/AgentsProvider.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ui/src/components/AgentsProvider.tsx b/ui/src/components/AgentsProvider.tsx index e26d62a57..90f5fe010 100644 --- a/ui/src/components/AgentsProvider.tsx +++ b/ui/src/components/AgentsProvider.tsx @@ -313,6 +313,27 @@ export function AgentsProvider({ children }: AgentsProviderProps) { fetchModels(); }, [fetchAgents, fetchTools, fetchModels]); + // Poll every 5 s while any agent is not yet ready. Stops automatically once + // all agents report deploymentReady=true, avoiding unnecessary API calls + // during steady-state operation. + useEffect(() => { + if (loading) return; + const hasNotReady = agents.some(a => !a.deploymentReady); + if (!hasNotReady) return; + + const id = setInterval(async () => { + const result = await getAgents(); + if (result.data) { + setAgents(result.data); + if (result.data.every(a => a.deploymentReady)) { + clearInterval(id); + } + } + }, 5000); + + return () => clearInterval(id); + }, [agents, loading]); + const value = { agents, models, From c364457c70ff43eff6b2e44ccf7886451f9b111a Mon Sep 17 00:00:00 2001 From: Tom O'Rourke Date: Sat, 18 Apr 2026 17:54:49 +0100 Subject: [PATCH 2/2] Address Copilot review: fix polling race conditions, error handling, add tests - Use derived hasNotReady boolean as effect dependency instead of the full agents array (avoids interval teardown/recreate on every update) - Replace setInterval with self-scheduling setTimeout to prevent overlapping requests when getAgents() is slow - Surface errors via setError and stop polling after 3 consecutive failures (was silently polling forever) - Add unit tests: no-poll when ready, poll-then-stop, error backoff Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/AgentsProvider.tsx | 50 ++++-- .../__tests__/AgentsProvider.polling.test.tsx | 147 ++++++++++++++++++ 2 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 ui/src/components/__tests__/AgentsProvider.polling.test.tsx diff --git a/ui/src/components/AgentsProvider.tsx b/ui/src/components/AgentsProvider.tsx index 90f5fe010..0d2e91dea 100644 --- a/ui/src/components/AgentsProvider.tsx +++ b/ui/src/components/AgentsProvider.tsx @@ -316,23 +316,53 @@ export function AgentsProvider({ children }: AgentsProviderProps) { // Poll every 5 s while any agent is not yet ready. Stops automatically once // all agents report deploymentReady=true, avoiding unnecessary API calls // during steady-state operation. + // + // Uses a derived boolean (hasNotReady) as the dependency instead of the full + // agents array so the effect only starts/stops — never tears down mid-poll. + // Self-scheduling setTimeout avoids overlapping requests when getAgents() is + // slow, and errors are surfaced via setError with a stop after 3 consecutive + // failures to prevent infinite silent polling. + const hasNotReady = React.useMemo( + () => !loading && agents.length > 0 && agents.some(a => !a.deploymentReady), + [agents, loading], + ); + useEffect(() => { - if (loading) return; - const hasNotReady = agents.some(a => !a.deploymentReady); if (!hasNotReady) return; - const id = setInterval(async () => { + let cancelled = false; + let timeoutId: ReturnType | undefined; + let consecutiveErrors = 0; + + const poll = async () => { const result = await getAgents(); - if (result.data) { - setAgents(result.data); - if (result.data.every(a => a.deploymentReady)) { - clearInterval(id); + if (cancelled) return; + + if (result.error || !result.data) { + consecutiveErrors++; + if (consecutiveErrors >= 3) { + setError(result.error || "Failed to refresh agents"); + return; // stop polling after repeated failures } + timeoutId = setTimeout(poll, 5000); + return; } - }, 5000); - return () => clearInterval(id); - }, [agents, loading]); + consecutiveErrors = 0; + setAgents(result.data); + + if (!result.data.every(a => a.deploymentReady)) { + timeoutId = setTimeout(poll, 5000); + } + }; + + timeoutId = setTimeout(poll, 5000); + + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [hasNotReady]); const value = { agents, diff --git a/ui/src/components/__tests__/AgentsProvider.polling.test.tsx b/ui/src/components/__tests__/AgentsProvider.polling.test.tsx new file mode 100644 index 000000000..bf1ec4de3 --- /dev/null +++ b/ui/src/components/__tests__/AgentsProvider.polling.test.tsx @@ -0,0 +1,147 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import React from "react"; +import { render, act, waitFor } from "@testing-library/react"; +import { AgentsProvider, useAgents } from "../AgentsProvider"; + +// ── Mocks ─────────────────────────────────────────────────────────────────── + +const mockGetAgents = jest.fn(); +const mockGetTools = jest.fn(); +const mockGetModelConfigs = jest.fn(); + +jest.mock("@/app/actions/agents", () => ({ + getAgents: (...args: unknown[]) => mockGetAgents(...args), + getAgent: jest.fn().mockResolvedValue({ data: null }), + createAgent: jest.fn().mockResolvedValue({ data: {} }), +})); + +jest.mock("@/app/actions/tools", () => ({ + getTools: (...args: unknown[]) => mockGetTools(...args), +})); + +jest.mock("@/app/actions/modelConfigs", () => ({ + getModelConfigs: (...args: unknown[]) => mockGetModelConfigs(...args), +})); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function makeAgent(name: string, ready: boolean) { + return { + agent: { metadata: { name, namespace: "default" }, spec: {} }, + deploymentReady: ready, + }; +} + +/** Renders an invisible consumer that exposes context values via a ref. */ +function renderProvider() { + const ref: { current: ReturnType | null } = { current: null }; + function Consumer() { + ref.current = useAgents(); + return null; + } + const utils = render( + + + , + ); + return { ref, ...utils }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("AgentsProvider polling", () => { + beforeEach(() => { + jest.useFakeTimers(); + mockGetTools.mockResolvedValue([]); + mockGetModelConfigs.mockResolvedValue({ data: [] }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("does not poll when all agents are ready", async () => { + mockGetAgents.mockResolvedValue({ + data: [makeAgent("a", true), makeAgent("b", true)], + }); + + await act(async () => { + renderProvider(); + }); + + // Advance well past the 5 s poll interval + await act(async () => { + jest.advanceTimersByTime(15_000); + }); + + // Initial fetch only — no polling calls + expect(mockGetAgents).toHaveBeenCalledTimes(1); + }); + + it("polls while at least one agent is not ready and stops when all become ready", async () => { + // Initial fetch: one agent not ready + mockGetAgents.mockResolvedValueOnce({ + data: [makeAgent("a", true), makeAgent("b", false)], + }); + + await act(async () => { + renderProvider(); + }); + + // First poll — still not ready + mockGetAgents.mockResolvedValueOnce({ + data: [makeAgent("a", true), makeAgent("b", false)], + }); + + await act(async () => { + jest.advanceTimersByTime(5_000); + }); + + // Second poll — now all ready + mockGetAgents.mockResolvedValueOnce({ + data: [makeAgent("a", true), makeAgent("b", true)], + }); + + await act(async () => { + jest.advanceTimersByTime(5_000); + }); + + // No more polls after becoming ready + await act(async () => { + jest.advanceTimersByTime(15_000); + }); + + // 1 initial + 2 polls = 3 total + expect(mockGetAgents).toHaveBeenCalledTimes(3); + }); + + it("stops polling after 3 consecutive errors", async () => { + // Initial: not ready + mockGetAgents.mockResolvedValueOnce({ + data: [makeAgent("a", false)], + }); + + const { ref } = await act(async () => renderProvider()); + + // 3 consecutive failures + for (let i = 0; i < 3; i++) { + mockGetAgents.mockResolvedValueOnce({ error: "network error", data: null }); + await act(async () => { + jest.advanceTimersByTime(5_000); + }); + } + + // Should have stopped — no more calls after advancing further + const callsBefore = mockGetAgents.mock.calls.length; + await act(async () => { + jest.advanceTimersByTime(15_000); + }); + + expect(mockGetAgents.mock.calls.length).toBe(callsBefore); + // Error should be surfaced + await waitFor(() => { + expect(ref.current?.error).toBeTruthy(); + }); + }); +});