diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx new file mode 100644 index 00000000000..ea8a02f03c9 --- /dev/null +++ b/ui/src/pages/Agents.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Agent } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Agents } from "./Agents"; + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), + org: vi.fn(), +})); + +const mockHeartbeatsApi = vi.hoisted(() => ({ + liveRunsForCompany: vi.fn(), +})); + +const mockOpenNewAgent = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + {children} + ), + useLocation: () => ({ pathname: "/agents/all", search: "", hash: "", state: null }), + useNavigate: () => vi.fn(), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("../context/DialogContext", () => ({ + useDialogActions: () => ({ openNewAgent: mockOpenNewAgent }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ isMobile: false }), +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: mockHeartbeatsApi, +})); + +vi.mock("../adapters/adapter-display-registry", () => ({ + getAdapterLabel: (type: string) => type, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function makeAgent(overrides: Partial): Agent { + return { + id: "agent-1", + companyId: "company-1", + name: "Alpha", + urlKey: "alpha", + role: "engineer", + title: null, + icon: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("Agents", () => { + let container: HTMLDivElement; + let root: ReturnType | null; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ adapterConfig: { model: "gpt-5.4" } }), + ]); + mockAgentsApi.org.mockResolvedValue([ + { + id: "agent-1", + name: "Alpha", + role: "engineer", + status: "active", + reports: [], + }, + ]); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + }); + + afterEach(async () => { + const currentRoot = root; + if (currentRoot) { + await act(async () => { + currentRoot.unmount(); + }); + } + queryClient.clear(); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("shows the configured model beside the adapter on the all agents page", async () => { + root = createRoot(container); + await act(async () => { + root!.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("codex_local"); + expect(container.textContent).toContain("gpt-5.4"); + }); +}); diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 179a7c065ea..6e055da915b 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -41,6 +41,13 @@ function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): .sort((a, b) => a.name.localeCompare(b.name)); } +function getConfiguredModel(agent: Agent): string | null { + const value = agent.adapterConfig?.model; + if (typeof value !== "string") return null; + const model = value.trim(); + return model.length > 0 ? model : null; +} + function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] { return nodes .reduce((acc, node) => { @@ -253,9 +260,15 @@ export function Agents() { liveCount={liveRunByAgent.get(agent.id)!.liveCount} /> )} - + {getAdapterLabel(agent.adapterType)} + + {getConfiguredModel(agent) ?? "—"} + {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} @@ -356,9 +369,15 @@ function OrgTreeNode({ )} {agent && ( <> - + {getAdapterLabel(agent.adapterType)} + + {getConfiguredModel(agent) ?? "—"} + {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}