/** * @file SessionDetail.nestedAgents.test.tsx * @description Tests for SessionDetail page focusing on correct rendering of nested agent hierarchies, including edge cases like orphaned subagents and multiple main agents. Validates expand/collapse behavior and descendant counts in the UI. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, within, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { SessionDetail } from "../SessionDetail"; import type { Agent, Session, DashboardEvent } from "../../lib/types"; import { fmtCost } from "../../lib/format"; // ── Helpers ────────────────────────────────────────────────────────────────── function makeAgent(overrides: Partial = {}): Agent { return { id: "agent-1", session_id: "sess-1", name: "Main Agent", type: "main", subagent_type: null, status: "working", task: null, current_tool: null, started_at: "2026-03-05T10:00:00.000Z", ended_at: null, updated_at: "2026-03-05T10:00:00.000Z", parent_agent_id: null, metadata: null, ...overrides, }; } const mockSession: Session = { id: "sess-1", name: "Test Session", status: "active", cwd: "/test", model: "claude-opus-4-6", started_at: "2026-03-05T10:00:00.000Z", ended_at: null, metadata: null, }; // ── Mock API ───────────────────────────────────────────────────────────────── let mockAgents: Agent[] = []; let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, breakdown: [] }; vi.mock("../../lib/api", () => ({ api: { sessions: { get: vi.fn(() => Promise.resolve({ session: mockSession, agents: mockAgents, events: [] as DashboardEvent[], }) ), transcripts: vi.fn(() => Promise.resolve({ transcripts: [] })), stats: vi.fn(() => Promise.resolve({ session_id: mockSession.id, total_events: 0, events_by_type: [], tools_used: [], error_count: 0, first_event_at: null, last_event_at: null, agents: { total: 0, main: 0, subagent: 0, compaction: 0, by_status: {} }, subagent_types: [], tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, }, }) ), }, pricing: { sessionCost: vi.fn(() => Promise.resolve(mockCost)), }, events: { list: vi.fn(() => Promise.resolve({ events: [] as DashboardEvent[], limit: 50, offset: 0, total: 0, }) ), facets: vi.fn(() => Promise.resolve({ event_types: [], tool_names: [] })), }, }, })); vi.mock("../../lib/eventBus", () => ({ eventBus: { subscribe: vi.fn(() => () => {}), }, })); function renderPage() { return render( } /> ); } /** Get the agent-tree region for scoped queries (avoids matches in the active-agent banner). */ async function findTree() { return await screen.findByTestId("agent-tree"); } // ── Tests ──────────────────────────────────────────────────────────────────── describe("SessionDetail - Nested Agent Tree Rendering", () => { beforeEach(() => { mockAgents = []; mockCost = { total_cost: 0, breakdown: [] }; }); it("shows the session total cost on the main agent card (cost is loaded separately)", async () => { // Regression: /api/sessions/:id has no cost column, so the main card (which // renders session.cost) showed nothing until SessionDetail injected the // separately-loaded total into the card's session prop. mockCost = { total_cost: 42.5, breakdown: [] }; mockAgents = [makeAgent({ id: "main-1", name: "Main Agent", type: "main", status: "waiting" })]; renderPage(); const tree = await findTree(); await waitFor(() => expect(within(tree).getByText(fmtCost(42.5))).toBeInTheDocument()); }); it("renders a flat main → subagent hierarchy (depth 1)", async () => { mockAgents = [ makeAgent({ id: "main-1", name: "Main Agent", type: "main", status: "working" }), makeAgent({ id: "sub-1", name: "Explorer", type: "subagent", subagent_type: "Explore", status: "working", parent_agent_id: "main-1", }), ]; renderPage(); expect(await screen.findByText("Main Agent")).toBeInTheDocument(); // Subagent should be visible (auto-expanded because it's working) expect(await screen.findByText("Explorer")).toBeInTheDocument(); }); it("renders deeply nested agents (depth 3: main → L1 → L2 → L3)", async () => { mockAgents = [ makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }), makeAgent({ id: "l1", name: "Level-1", type: "subagent", status: "working", parent_agent_id: "main-1", }), makeAgent({ id: "l2", name: "Level-2", type: "subagent", status: "working", parent_agent_id: "l1", }), makeAgent({ id: "l3", name: "Level-3", type: "subagent", status: "working", parent_agent_id: "l2", }), ]; renderPage(); const tree = await findTree(); // All levels should render in the tree (auto-expanded because they have working children). // The active-agent banner may also display "Level-1", so we scope to the tree. await waitFor(() => expect(within(tree).getByText("Main")).toBeInTheDocument()); expect(within(tree).getByText("Level-1")).toBeInTheDocument(); expect(within(tree).getByText("Level-2")).toBeInTheDocument(); expect(within(tree).getByText("Level-3")).toBeInTheDocument(); }); it("shows descendant count in collapsed badge for nested agents", async () => { mockAgents = [ makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }), makeAgent({ id: "l1", name: "Level-1", type: "subagent", status: "completed", parent_agent_id: "main-1", }), makeAgent({ id: "l2", name: "Level-2", type: "subagent", status: "completed", parent_agent_id: "l1", }), makeAgent({ id: "l3a", name: "Level-3a", type: "subagent", status: "completed", parent_agent_id: "l2", }), makeAgent({ id: "l3b", name: "Level-3b", type: "subagent", status: "completed", parent_agent_id: "l2", }), ]; renderPage(); // Main has 4 total descendants (L1, L2, L3a, L3b) - should show "4 subagents" when collapsed expect(await screen.findByText("4 subagents")).toBeInTheDocument(); }); it("expands and collapses nested agent groups", async () => { mockAgents = [ makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }), makeAgent({ id: "l1", name: "Level-1", type: "subagent", status: "completed", parent_agent_id: "main-1", }), makeAgent({ id: "l2", name: "Level-2", type: "subagent", status: "completed", parent_agent_id: "l1", }), ]; renderPage(); // Initially collapsed (agents are completed, no auto-expand) expect(await screen.findByText("2 subagents")).toBeInTheDocument(); expect(screen.queryByText("Level-1")).not.toBeInTheDocument(); // Click the count badge to expand fireEvent.click(screen.getByText("2 subagents")); expect(await screen.findByText("Level-1")).toBeInTheDocument(); // Level-2 is still nested under Level-1 which is collapsed expect(screen.getByText("1 subagent")).toBeInTheDocument(); }); it("renders orphaned subagents in dedicated section", async () => { mockAgents = [ makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }), makeAgent({ id: "orphan-1", name: "Orphan Agent", type: "subagent", status: "working", parent_agent_id: "nonexistent-parent", }), ]; renderPage(); const tree = await findTree(); expect(within(tree).getByText("Main")).toBeInTheDocument(); expect(within(tree).getByText("Orphan Agent")).toBeInTheDocument(); expect(await screen.findByText("Unparented Subagents")).toBeInTheDocument(); }); it("auto-expands all ancestors when a deeply nested agent is active", async () => { // Level-3 is working → Level-2, Level-1, and Main should all auto-expand mockAgents = [ makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }), makeAgent({ id: "l1", name: "Level-1", type: "subagent", status: "working", parent_agent_id: "main-1", }), makeAgent({ id: "l2", name: "Level-2", type: "subagent", status: "working", parent_agent_id: "l1", }), makeAgent({ id: "l3", name: "Deep Active", type: "subagent", status: "working", parent_agent_id: "l2", }), ]; renderPage(); const tree = await findTree(); // All levels should be visible because l3 is working, triggering ancestor expansion await waitFor(() => expect(within(tree).getByText("Main")).toBeInTheDocument()); expect(within(tree).getByText("Level-1")).toBeInTheDocument(); expect(within(tree).getByText("Level-2")).toBeInTheDocument(); expect(within(tree).getByText("Deep Active")).toBeInTheDocument(); }); it("handles agents with no children (leaf nodes)", async () => { mockAgents = [makeAgent({ id: "main-1", name: "Main", type: "main", status: "working" })]; renderPage(); expect(await screen.findByText("Main")).toBeInTheDocument(); // No subagent-count button should exist for a leaf node. Match the // "{{count}} subagent(s)" label specifically so we don't collide with // the word "subagent" in unrelated explanatory copy elsewhere on the page. expect(screen.queryByText(/\d+ subagent/)).not.toBeInTheDocument(); }); it("renders multiple main agents in the same session", async () => { // Edge case: import/resume could create multiple "main" entries mockAgents = [ makeAgent({ id: "main-1", name: "Main-A", type: "main", status: "completed" }), makeAgent({ id: "main-2", name: "Main-B", type: "main", status: "working" }), makeAgent({ id: "sub-a", name: "Sub of A", type: "subagent", status: "completed", parent_agent_id: "main-1", }), makeAgent({ id: "sub-b", name: "Sub of B", type: "subagent", status: "working", parent_agent_id: "main-2", }), ]; renderPage(); const tree = await findTree(); await waitFor(() => expect(within(tree).getByText("Main-A")).toBeInTheDocument()); expect(within(tree).getByText("Main-B")).toBeInTheDocument(); // Sub of B auto-expanded (working) expect(within(tree).getByText("Sub of B")).toBeInTheDocument(); }); it("renders sibling subagents at the same depth", async () => { mockAgents = [ makeAgent({ id: "main-1", name: "Main", type: "main", status: "working" }), makeAgent({ id: "sub-a", name: "Sibling-A", type: "subagent", status: "working", parent_agent_id: "main-1", }), makeAgent({ id: "sub-b", name: "Sibling-B", type: "subagent", status: "working", parent_agent_id: "main-1", }), makeAgent({ id: "sub-c", name: "Sibling-C", type: "subagent", status: "completed", parent_agent_id: "main-1", }), ]; renderPage(); expect(await screen.findByText("Main")).toBeInTheDocument(); expect(await screen.findByText("Sibling-A")).toBeInTheDocument(); expect(await screen.findByText("Sibling-B")).toBeInTheDocument(); expect(await screen.findByText("Sibling-C")).toBeInTheDocument(); }); });