2026-02-24 18:03:08 +00:00
|
|
|
import { act, render, screen } from "@testing-library/react";
|
2026-02-23 16:04:02 +00:00
|
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
2026-02-24 18:03:08 +00:00
|
|
|
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "../api/agents";
|
|
|
|
|
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
2026-02-20 12:48:50 +00:00
|
|
|
|
|
|
|
|
vi.mock("../api/agents", () => {
|
|
|
|
|
const agentsApi = {
|
|
|
|
|
listAgents: vi.fn(),
|
|
|
|
|
getAgentConfig: vi.fn(),
|
|
|
|
|
startAgent: vi.fn(),
|
|
|
|
|
stopAgent: vi.fn(),
|
|
|
|
|
reloadConfig: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
return { agentsApi, subscribeAgentStream: vi.fn(() => () => {}) };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Dynamic import so the mock is in place before the module loads
|
|
|
|
|
const { AgentPanel } = await import("./AgentPanel");
|
|
|
|
|
|
2026-02-24 18:03:08 +00:00
|
|
|
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
|
|
|
|
|
2026-02-20 12:48:50 +00:00
|
|
|
const mockedAgents = {
|
|
|
|
|
listAgents: vi.mocked(agentsApi.listAgents),
|
|
|
|
|
getAgentConfig: vi.mocked(agentsApi.getAgentConfig),
|
|
|
|
|
startAgent: vi.mocked(agentsApi.startAgent),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ROSTER: AgentConfigInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
name: "coder-1",
|
|
|
|
|
role: "Full-stack engineer",
|
|
|
|
|
model: "sonnet",
|
|
|
|
|
allowed_tools: null,
|
|
|
|
|
max_turns: 50,
|
|
|
|
|
max_budget_usd: 5.0,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-23 16:04:02 +00:00
|
|
|
describe("AgentPanel active work list removed", () => {
|
2026-02-20 12:48:50 +00:00
|
|
|
beforeAll(() => {
|
|
|
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-23 16:04:02 +00:00
|
|
|
it("does not render active agent entries even when agents are running", async () => {
|
2026-02-20 12:48:50 +00:00
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
2026-02-23 16:04:02 +00:00
|
|
|
story_id: "83_active",
|
2026-02-20 12:48:50 +00:00
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
2026-02-23 16:04:02 +00:00
|
|
|
base_branch: "master",
|
2026-02-23 20:52:06 +00:00
|
|
|
log_session_id: null,
|
2026-02-23 14:27:15 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
const { container } = render(<AgentPanel />);
|
|
|
|
|
|
2026-02-23 16:04:02 +00:00
|
|
|
// Roster badge should still be visible
|
|
|
|
|
await screen.findByTestId("roster-badge-coder-1");
|
2026-02-23 14:27:15 +00:00
|
|
|
|
2026-02-23 16:04:02 +00:00
|
|
|
// No agent entry divs should exist
|
|
|
|
|
expect(
|
|
|
|
|
container.querySelector('[data-testid^="agent-entry-"]'),
|
|
|
|
|
).not.toBeInTheDocument();
|
2026-02-23 14:27:15 +00:00
|
|
|
});
|
|
|
|
|
});
|
2026-02-23 15:47:28 +00:00
|
|
|
|
2026-02-24 13:59:10 +00:00
|
|
|
describe("Running count visibility in header", () => {
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// AC1: When no agents are running, "0 running" is NOT visible
|
|
|
|
|
it("does not show running count when no agents are running", async () => {
|
|
|
|
|
render(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
// Wait for roster to load
|
|
|
|
|
await screen.findByTestId("roster-badge-coder-1");
|
|
|
|
|
|
|
|
|
|
expect(screen.queryByText(/0 running/)).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// AC2: When agents are running, "N running" IS visible
|
|
|
|
|
it("shows running count when agents are running", async () => {
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "99_active",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
await screen.findByText(/1 running/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
describe("RosterBadge availability state", () => {
|
2026-02-23 14:27:15 +00:00
|
|
|
beforeAll(() => {
|
|
|
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
it("shows a green dot for an idle agent", async () => {
|
|
|
|
|
render(<AgentPanel />);
|
2026-02-23 14:27:15 +00:00
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
const dot = await screen.findByTestId("roster-dot-coder-1");
|
|
|
|
|
// JSDOM normalizes #3fb950 to rgb(63, 185, 80)
|
|
|
|
|
expect(dot.style.background).toBe("rgb(63, 185, 80)");
|
|
|
|
|
expect(dot.style.animation).toBe("");
|
2026-02-23 14:27:15 +00:00
|
|
|
});
|
|
|
|
|
|
2026-02-23 18:23:01 +00:00
|
|
|
it("shows grey badge styling for an idle agent", async () => {
|
2026-02-23 16:06:16 +00:00
|
|
|
render(<AgentPanel />);
|
2026-02-23 14:27:15 +00:00
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
const badge = await screen.findByTestId("roster-badge-coder-1");
|
2026-02-23 18:23:01 +00:00
|
|
|
// JSDOM normalizes #aaa18 to rgba(170, 170, 170, 0.094) and #aaa to rgb(170, 170, 170)
|
|
|
|
|
expect(badge.style.background).toBe("rgba(170, 170, 170, 0.094)");
|
|
|
|
|
expect(badge.style.color).toBe("rgb(170, 170, 170)");
|
2026-02-23 14:27:15 +00:00
|
|
|
});
|
|
|
|
|
|
2026-02-23 21:34:59 +00:00
|
|
|
// AC1: roster badge always shows idle (grey) even when agent is running
|
|
|
|
|
it("shows a static green dot for a running agent (roster always idle)", async () => {
|
2026-02-23 14:27:15 +00:00
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
2026-02-23 16:06:16 +00:00
|
|
|
story_id: "81_active",
|
2026-02-23 14:27:15 +00:00
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: null,
|
|
|
|
|
base_branch: null,
|
2026-02-23 20:52:06 +00:00
|
|
|
log_session_id: null,
|
2026-02-23 14:27:15 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
render(<AgentPanel />);
|
2026-02-23 14:27:15 +00:00
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
const dot = await screen.findByTestId("roster-dot-coder-1");
|
2026-02-23 18:23:01 +00:00
|
|
|
expect(dot.style.background).toBe("rgb(63, 185, 80)");
|
2026-02-23 21:34:59 +00:00
|
|
|
// Roster is always idle — no pulsing animation
|
|
|
|
|
expect(dot.style.animation).toBe("");
|
2026-02-23 14:27:15 +00:00
|
|
|
});
|
|
|
|
|
|
2026-02-23 21:34:59 +00:00
|
|
|
// AC1: roster badge always shows idle (grey) even when agent is running
|
|
|
|
|
it("shows grey (idle) badge styling for a running agent", async () => {
|
2026-02-23 14:27:15 +00:00
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
2026-02-23 16:06:16 +00:00
|
|
|
story_id: "81_active",
|
2026-02-23 14:27:15 +00:00
|
|
|
agent_name: "coder-1",
|
2026-02-23 16:06:16 +00:00
|
|
|
status: "running",
|
2026-02-23 14:27:15 +00:00
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: null,
|
|
|
|
|
base_branch: null,
|
2026-02-23 20:52:06 +00:00
|
|
|
log_session_id: null,
|
2026-02-23 14:27:15 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
render(<AgentPanel />);
|
2026-02-23 14:27:15 +00:00
|
|
|
|
2026-02-23 16:06:16 +00:00
|
|
|
const badge = await screen.findByTestId("roster-badge-coder-1");
|
2026-02-23 21:37:05 +00:00
|
|
|
// Always idle: grey background and grey text
|
|
|
|
|
expect(badge.style.background).toBe("rgba(170, 170, 170, 0.094)");
|
|
|
|
|
expect(badge.style.color).toBe("rgb(170, 170, 170)");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// AC2: after agent completes and returns to roster, badge shows idle
|
|
|
|
|
it("shows idle state after agent status changes from running to completed", async () => {
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "81_completed",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "completed",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: null,
|
|
|
|
|
base_branch: null,
|
2026-02-23 21:39:40 +00:00
|
|
|
log_session_id: null,
|
2026-02-23 21:34:59 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
const badge = await screen.findByTestId("roster-badge-coder-1");
|
|
|
|
|
const dot = screen.getByTestId("roster-dot-coder-1");
|
|
|
|
|
// Completed agent: badge is idle
|
|
|
|
|
expect(badge.style.background).toBe("rgba(170, 170, 170, 0.094)");
|
|
|
|
|
expect(badge.style.color).toBe("rgb(170, 170, 170)");
|
|
|
|
|
expect(dot.style.animation).toBe("");
|
2026-02-23 14:27:15 +00:00
|
|
|
});
|
|
|
|
|
});
|
2026-02-24 18:03:08 +00:00
|
|
|
|
|
|
|
|
describe("Thinking trace block in agent stream UI", () => {
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue([]);
|
|
|
|
|
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// AC1+AC2: thinking block renders with fixed max-height and is visually distinct
|
|
|
|
|
it("renders thinking block with max-height 96px when thinking event arrives", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "160_thinking",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
// Wait for the subscription to be set up
|
|
|
|
|
await screen.findByTestId("roster-badge-coder-1");
|
|
|
|
|
|
|
|
|
|
// Fire a thinking event
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "thinking",
|
|
|
|
|
story_id: "160_thinking",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "Let me consider the problem carefully...",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const block = screen.getByTestId("thinking-block");
|
|
|
|
|
expect(block).toBeInTheDocument();
|
|
|
|
|
// AC2: fixed max-height
|
|
|
|
|
expect(block.style.maxHeight).toBe("96px");
|
|
|
|
|
// AC2: overflow scrolling
|
|
|
|
|
expect(block.style.overflowY).toBe("auto");
|
|
|
|
|
// AC1: visually distinct — italic monospace font
|
|
|
|
|
expect(block.style.fontStyle).toBe("italic");
|
|
|
|
|
expect(block.style.fontFamily).toBe("monospace");
|
|
|
|
|
// Contains the thinking text
|
|
|
|
|
expect(block.textContent).toContain(
|
|
|
|
|
"Let me consider the problem carefully...",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// AC3: thinking block renders the "thinking" label
|
|
|
|
|
it("shows a thinking label in the block header", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "160_label",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(<AgentPanel />);
|
|
|
|
|
await screen.findByTestId("roster-badge-coder-1");
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "thinking",
|
|
|
|
|
story_id: "160_label",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "thinking...",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const block = screen.getByTestId("thinking-block");
|
|
|
|
|
expect(block.textContent).toContain("thinking");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// AC4: regular text output renders outside the thinking container
|
|
|
|
|
it("renders regular output outside the thinking block", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "160_output",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(<AgentPanel />);
|
|
|
|
|
await screen.findByTestId("roster-badge-coder-1");
|
|
|
|
|
|
|
|
|
|
// First: thinking event
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "thinking",
|
|
|
|
|
story_id: "160_output",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "thinking deeply",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Then: text output event
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "output",
|
|
|
|
|
story_id: "160_output",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "Here is the result.",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const thinkingBlock = screen.getByTestId("thinking-block");
|
|
|
|
|
const outputArea = screen.getByTestId("agent-output-coder-1");
|
|
|
|
|
|
|
|
|
|
// Thinking still visible
|
|
|
|
|
expect(thinkingBlock).toBeInTheDocument();
|
|
|
|
|
expect(thinkingBlock.textContent).toContain("thinking deeply");
|
|
|
|
|
|
|
|
|
|
// Output renders in a separate element, not inside the thinking block
|
|
|
|
|
expect(outputArea).toBeInTheDocument();
|
|
|
|
|
expect(outputArea.textContent).toContain("Here is the result.");
|
|
|
|
|
expect(thinkingBlock.contains(outputArea)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// AC5: thinking block remains visible when text starts
|
|
|
|
|
it("keeps thinking block visible after output arrives", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "160_persist",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(<AgentPanel />);
|
|
|
|
|
await screen.findByTestId("roster-badge-coder-1");
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "thinking",
|
|
|
|
|
story_id: "160_persist",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "my thoughts",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "output",
|
|
|
|
|
story_id: "160_persist",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "final answer",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Thinking block still in the DOM after output arrives
|
|
|
|
|
expect(screen.getByTestId("thinking-block")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|