diff --git a/src/App.test.tsx b/src/App.test.tsx index fc9f7857..93ed7a06 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -126,7 +126,10 @@ describe("App", () => { // wrapper — the heavier pages already get their own dedicated tests. const stubRoutesAndHeadings: [string, RegExp][] = [ ["/app/change-password", /change password/i], - ["/app/agents", /agents/i], + // Anchored: the page renders real content now (not a bare stub), and its + // empty state ("No agents yet") also contains the word "agents" — a loose + // /agents/i would match both and throw on ambiguity. + ["/app/agents", /^agents$/i], ["/app/rest-api", /rest api/i], ["/app/grpc", /grpc/i], ["/app/llm/providers", /llm providers/i], diff --git a/src/components/icons/AgentIcon.tsx b/src/components/icons/AgentIcon.tsx index 63fd6734..c2c43834 100644 --- a/src/components/icons/AgentIcon.tsx +++ b/src/components/icons/AgentIcon.tsx @@ -1,4 +1,6 @@ -export const AgentIcon = ({ className }: { className?: string }) => ( +import type { SVGProps } from "react"; + +export const AgentIcon = ({ className, ...props }: SVGProps) => ( ( viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > ; + +function createMockAgent(id: number, overrides: Partial = {}): Agent { + return { + id: `agent-${id}`, + name: `Agent ${id}`, + slug: `agent-${id}`, + description: `Description for agent ${id}`, + endpointUrl: `http://localhost/agents/${id}`, + agentType: "generic", + protocolVersion: "1.0", + capabilities: {}, + config: {}, + enabled: true, + reachable: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastInteraction: null, + tags: [], + ...overrides, + }; +} + +describe("Agents", () => { + it("renders loading state initially", async () => { + let resolveRequest: () => void; + const requestGate = new Promise((resolve) => { + resolveRequest = resolve; + }); + + server.use( + http.get("/api/a2a", async () => { + await requestGate; + return HttpResponse.json([]); + }), + ); + + renderWithProviders(); + + const status = screen.getByRole("status"); + expect(status).toBeInTheDocument(); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveAttribute("aria-busy", "true"); + expect(screen.getByText("Loading agents, please wait...")).toBeInTheDocument(); + + resolveRequest!(); + await waitFor(() => { + expect(screen.getByText("No agents yet")).toBeInTheDocument(); + }); + }); + + it("displays error message when the API call fails", async () => { + server.use( + http.get("/api/a2a", () => + HttpResponse.json({ detail: "Failed to fetch agents" }, { status: 500 }), + ), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + expect(screen.getByText("Error loading agents")).toBeInTheDocument(); + }); + + it("shows an empty state when there are no agents", async () => { + server.use(http.get("/api/a2a", () => HttpResponse.json([]))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("No agents yet")).toBeInTheDocument(); + }); + expect( + screen.getByText("Agents will appear here once they're registered with the gateway."), + ).toBeInTheDocument(); + expect(document.querySelectorAll('[data-slot="card"]')).toHaveLength(0); + }); + + it("renders a card per agent with its name and description", async () => { + const mockAgents = [createMockAgent(1), createMockAgent(2)]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Agent 1")).toBeInTheDocument(); + }); + expect(screen.getByText("Agent 2")).toBeInTheDocument(); + expect(screen.getByText("Description for agent 1")).toBeInTheDocument(); + expect(document.querySelectorAll('[data-slot="card"]')).toHaveLength(2); + }); + + it("omits the description when an agent has none", async () => { + const mockAgents = [createMockAgent(1, { description: "" })]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Agent 1")).toBeInTheDocument(); + }); + expect(screen.queryByText("Description for agent 1")).not.toBeInTheDocument(); + }); + + it("shows an active status indicator when the agent is enabled and reachable", async () => { + const mockAgents = [createMockAgent(1, { enabled: true, reachable: true })]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + expect(await screen.findByRole("img", { name: "Active" })).toBeInTheDocument(); + }); + + it("shows an inactive status indicator when the agent is disabled or unreachable", async () => { + const mockAgents = [createMockAgent(1, { enabled: false, reachable: true })]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + expect(await screen.findByRole("img", { name: "Inactive" })).toBeInTheDocument(); + }); + + it("renders tag chips for an agent's tags", async () => { + const mockAgents = [ + createMockAgent(1, { tags: [{ label: "billing" }, { label: "internal" }] }), + ]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("billing")).toBeInTheDocument(); + }); + expect(screen.getByText("internal")).toBeInTheDocument(); + }); + + it("caps visible tags at 8 and shows a +N overflow chip", async () => { + const mockAgents = [ + createMockAgent(1, { + tags: Array.from({ length: 10 }, (_, i) => ({ label: `tag-${i + 1}` })), + }), + ]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("tag-1")).toBeInTheDocument(); + }); + expect(screen.getByText("tag-8")).toBeInTheDocument(); + expect(screen.queryByText("tag-9")).not.toBeInTheDocument(); + expect(screen.getByText("+2")).toBeInTheDocument(); + }); + + it("renders without tags when the field is omitted entirely", async () => { + const agent = createMockAgent(1); + delete (agent as Partial).tags; + server.use(http.get("/api/a2a", () => HttpResponse.json([agent]))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Agent 1")).toBeInTheDocument(); + }); + }); + + it("ignores null entries returned by the API", async () => { + const mockAgents: (Agent | null)[] = [createMockAgent(1), null]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Agent 1")).toBeInTheDocument(); + }); + expect(document.querySelectorAll('[data-slot="card"]')).toHaveLength(1); + }); + + it("treats a non-array response as an empty list instead of crashing", async () => { + // The shape a broken/legacy backend response would take — guards the + // Array.isArray fallback rather than assuming `data` is always an array. + server.use(http.get("/api/a2a", () => HttpResponse.json({ agents: [] }))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("No agents yet")).toBeInTheDocument(); + }); + }); + + it("requests the list with include_inactive=true so disabled agents stay listed", async () => { + let requestedUrl: URL | undefined; + server.use( + http.get("/api/a2a", ({ request }) => { + requestedUrl = new URL(request.url); + return HttpResponse.json([]); + }), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("No agents yet")).toBeInTheDocument(); + }); + expect(requestedUrl?.searchParams.get("include_inactive")).toBe("true"); + expect(requestedUrl?.searchParams.get("limit")).toBe("0"); + }); + + it("uses correct grid layout classes", async () => { + const mockAgents = [createMockAgent(1)]; + server.use(http.get("/api/a2a", () => HttpResponse.json(mockAgents))); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Agent 1")).toBeInTheDocument(); + }); + + const gridContainer = screen.getByText("Agent 1").closest('[data-slot="card"]')?.parentElement; + expect(gridContainer).toBeInTheDocument(); + expect(gridContainer).toHaveClass("grid"); + expect(gridContainer).toHaveClass("grid-cols-1"); + expect(gridContainer).toHaveClass("lg:grid-cols-2"); + expect(gridContainer).toHaveClass("2xl:grid-cols-3"); + }); +}); diff --git a/src/pages/Agents.tsx b/src/pages/Agents.tsx index 499bc539..009468b8 100644 --- a/src/pages/Agents.tsx +++ b/src/pages/Agents.tsx @@ -1,3 +1,145 @@ +import { useMemo } from "react"; +import { useIntl } from "react-intl"; +import { useQuery } from "@/hooks/useQuery"; +import { getTagLabels } from "@/utils/tags"; +import type { Agent } from "@/types/agent"; +import type { A2AAgentRead } from "@/generated/types"; +import { Card, CardHeader, CardContent } from "@/components/ui/card"; +import { CardTag } from "@/components/ui/card-tag"; +import { Typography } from "@/components/ui/typography"; +import { AgentIcon } from "@/components/icons/AgentIcon"; + +// Only the fields the card actually renders — `Agent` also carries credential +// fields (authToken, authPassword, ...) that must never reach the UI. Typing +// the prop as this narrower `Pick` (rather than the full `Agent`) turns a +// stray `{agent.authToken}` into a type error instead of a silent leak. +type AgentCardFields = Pick< + Agent, + "id" | "name" | "description" | "enabled" | "reachable" | "tags" +>; + +function AgentCard({ agent }: { agent: AgentCardFields }) { + const intl = useIntl(); + const MAX_VISIBLE_TAGS = 8; + const isActive = agent.enabled && agent.reachable; + const tags = getTagLabels(agent.tags ?? []); + const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS); + const remainingCount = tags.length - MAX_VISIBLE_TAGS; + + return ( + + +
+
+
+ +
+ + {agent.name} + + +
+
+ + + + {agent.description && ( + + {agent.description} + + )} + {tags.length > 0 && ( +
+ {visibleTags.map((tag, index) => ( + // Index in the key too: labels aren't guaranteed unique, and the + // label alone would collide for a repeated tag on one agent. + {tag} + ))} + {remainingCount > 0 && +{remainingCount}} +
+ )} +
+ + ); +} + export function Agents() { - return

Agents

; + const intl = useIntl(); + const { + data: agentsData, + error, + isLoading, + } = useQuery("/a2a?limit=0&include_inactive=true"); + + const agents = useMemo( + () => + (Array.isArray(agentsData) ? agentsData : []).filter( + (agent): agent is Agent => agent !== null, + ), + [agentsData], + ); + + return ( +
+ + {intl.formatMessage({ id: "agents.title" })} + + + {isLoading && ( +
+ {intl.formatMessage({ id: "agents.loading" })} +
+
+ )} + + {error && ( +
+ {/* A label, not a structural heading — an

here would skip + straight from the page's

with no

in between. */} +

{intl.formatMessage({ id: "agents.error.loading" })}

+

{error.message}

+

+ )} + + {!isLoading && !error && agents.length === 0 && ( +
+ + {intl.formatMessage({ id: "agents.empty.title" })} + + + {intl.formatMessage({ id: "agents.empty.description" })} + +
+ )} + + {!isLoading && !error && agents.length > 0 && ( +
+ {agents.map((agent, index) => ( + // `A2AAgentRead.id` types as string | null | undefined; fall back + // to index so a null/missing id can't collide with another card. + + ))} +
+ )} +
+ ); } diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index 55d64b90..a0e05630 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -3,6 +3,11 @@ import { http, HttpResponse } from "msw"; import { RECENT_ACTIVITY_FIXTURE } from "@/mocks/recentActivity"; export const handlers = [ + // Mock A2A agents list endpoint — empty by default so any test that + // renders without its own server.use(...) override doesn't hit + // an unhandled request. + http.get("*/api/a2a", () => HttpResponse.json([])), + // Mock Recent Activity endpoint — backed by RECENT_ACTIVITY_FIXTURE. http.get("*/api/logs/activity", ({ request }) => { const url = new URL(request.url); diff --git a/src/types/agent.ts b/src/types/agent.ts new file mode 100644 index 00000000..e1efd6eb --- /dev/null +++ b/src/types/agent.ts @@ -0,0 +1,17 @@ +import type { A2AAgentRead } from "@/generated/types"; + +/** + * An A2A agent as returned by the API. + * + * Aliased to the generated OpenAPI `A2AAgentRead` (unwrapped from its `| null`) + * so the UI stays in lockstep with the backend contract. Mirrors the pattern + * used for tools (`NonNullable`). + * + * NEVER RENDER: `authValue`, `authUsername`, `authPassword`, `authToken`, + * `authHeaderKey`, `authHeaderValue`, `authQueryParamKey`, and + * `authQueryParamValueMasked` carry credential material. Components that + * display an `Agent` should narrow to a `Pick` of the fields + * they actually use instead of accepting the whole object, so a stray + * `{agent.authToken}` fails to typecheck rather than leaking to the UI. + */ +export type Agent = NonNullable;