feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* @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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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> = {}): 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(
|
||||
<MemoryRouter initialEntries={["/sessions/sess-1"]}>
|
||||
<Routes>
|
||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user