Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions ui/src/components/AgentsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,57 @@ 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.
//
// 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 (!hasNotReady) return;

let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let consecutiveErrors = 0;

const poll = async () => {
const result = await getAgents();
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;
}

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,
models,
Expand Down
147 changes: 147 additions & 0 deletions ui/src/components/__tests__/AgentsProvider.polling.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useAgents> | null } = { current: null };
function Consumer() {
ref.current = useAgents();
return null;
}
const utils = render(
<AgentsProvider>
<Consumer />
</AgentsProvider>,
);
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();
});
});
});