Skip to content
Merged
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
5 changes: 4 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
5 changes: 4 additions & 1 deletion src/components/icons/AgentIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
export const AgentIcon = ({ className }: { className?: string }) => (
import type { SVGProps } from "react";

export const AgentIcon = ({ className, ...props }: SVGProps<SVGSVGElement>) => (
<svg
className={className}
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M11.5 9.84375V11.5312M6.5 9.84375V11.5312M9 4.78125V2.25M9 4.78125C4.85786 4.78125 1.5 7.61445 1.5 11.1094C1.5 14.6043 4.85786 15.75 9 15.75C13.1421 15.75 16.5 14.6043 16.5 11.1094C16.5 7.61445 13.1421 4.78125 9 4.78125Z"
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/en-US/agents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"agents.title": "Agents",
"agents.loading": "Loading agents, please wait...",
"agents.error.loading": "Error loading agents",
"agents.card.status.active": "Active",
"agents.card.status.inactive": "Inactive",
"agents.empty.title": "No agents yet",
"agents.empty.description": "Agents will appear here once they're registered with the gateway."

@a-effort a-effort Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please change "gateway" to "server" (moving away from using that terminology other than the product being an AI Gateway). Request applies to all copy (i18N)

}
2 changes: 2 additions & 0 deletions src/i18n/locales/en-US/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import teams from "./teams.json";
import tools from "./tools.json";
import resources from "./resources.json";
import prompts from "./prompts.json";
import agents from "./agents.json";
import mcpServer from "./mcpServer.json";
import settings from "./settings.json";
import tokens from "./tokens.json";
Expand All @@ -28,6 +29,7 @@ export default {
...tools,
...resources,
...prompts,
...agents,
...mcpServer,
...settings,
...tokens,
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/es-ES/agents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"agents.title": "Agentes",
"agents.loading": "Cargando agentes, por favor espere...",
"agents.error.loading": "Error al cargar los agentes",
"agents.card.status.active": "Activo",
"agents.card.status.inactive": "Inactivo",
"agents.empty.title": "Aún no hay agentes",
"agents.empty.description": "Los agentes aparecerán aquí una vez que se registren en la puerta de enlace."
}
2 changes: 2 additions & 0 deletions src/i18n/locales/es-ES/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import teams from "./teams.json";
import tools from "./tools.json";
import resources from "./resources.json";
import prompts from "./prompts.json";
import agents from "./agents.json";
import mcpServer from "./mcpServer.json";
import settings from "./settings.json";
import tokens from "./tokens.json";
Expand All @@ -28,6 +29,7 @@ export default {
...tools,
...resources,
...prompts,
...agents,
...mcpServer,
...settings,
...tokens,
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/pt-BR/agents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"agents.title": "Agentes",
"agents.loading": "Carregando agentes, aguarde...",
"agents.error.loading": "Erro ao carregar os agentes",
"agents.card.status.active": "Ativo",
"agents.card.status.inactive": "Inativo",
"agents.empty.title": "Ainda não há agentes",
"agents.empty.description": "Os agentes aparecerão aqui assim que forem registrados no gateway."
}
2 changes: 2 additions & 0 deletions src/i18n/locales/pt-BR/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import teams from "./teams.json";
import tools from "./tools.json";
import resources from "./resources.json";
import prompts from "./prompts.json";
import agents from "./agents.json";
import mcpServer from "./mcpServer.json";
import settings from "./settings.json";
import tokens from "./tokens.json";
Expand All @@ -28,6 +29,7 @@ export default {
...tools,
...resources,
...prompts,
...agents,
...mcpServer,
...settings,
...tokens,
Expand Down
4 changes: 4 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
--color-tool-icon-bg: var(--tool-icon-bg);
--color-prompt-icon-bg: var(--prompt-icon-bg);
--color-server-icon-bg: var(--server-icon-bg);
--color-agent-icon-bg: var(--agent-icon-bg);
--color-tool-status-active: var(--tool-status-active);
--color-tool-status-inactive: var(--tool-status-inactive);
--color-tool-badge-bg: var(--tool-badge-bg);
Expand Down Expand Up @@ -150,6 +151,9 @@
black glyph, so it needs no `.dark` override — only `--status-icon`, which
paints a foreground, has to step down on white. */
--server-icon-bg: #70f9ff;
/* Same rationale as the icon-bg tokens above: a chip background carrying a
black glyph, no `.dark` override needed. */
--agent-icon-bg: #ffc078;
/* emerald-400 falls under 3:1 on white; .dark restores it */
--tool-status-active: var(--color-emerald-600);
--tool-status-inactive: var(--color-neutral-500);
Expand Down
236 changes: 236 additions & 0 deletions src/pages/Agents.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { describe, it, expect } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
import { Agents } from "./Agents";
import { renderWithProviders } from "@/test/test-utils";
import type { A2AAgentRead } from "@/generated/types";

type Agent = NonNullable<A2AAgentRead>;

function createMockAgent(id: number, overrides: Partial<Agent> = {}): 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<void>((resolve) => {
resolveRequest = resolve;
});

server.use(
http.get("/api/a2a", async () => {
await requestGate;
return HttpResponse.json([]);
}),
);

renderWithProviders(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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<Agent>).tags;
server.use(http.get("/api/a2a", () => HttpResponse.json([agent])));

renderWithProviders(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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(<Agents />);

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");
});
});
Loading
Loading