import { describe, it, expect, vi } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { DIProvider } from "@/app/di"; import { MockAgentGateway, MockLayoutGateway, MockSystemGateway, MockWorkStateGateway, } from "@/adapters/mock"; import { leaves } from "@/features/layout/layout"; import type { Gateways } from "@/ports"; import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel"; const PROJECT_ID = "project-work-state-test"; function renderPanel( workState: MockWorkStateGateway = new MockWorkStateGateway(), system: MockSystemGateway = new MockSystemGateway(), overrides: Partial = {}, ) { const gateways = { workState, system, ...overrides } as unknown as Gateways; return { workState, system, ...render( , ), }; } describe("ProjectWorkStatePanel", () => { it("renders the empty state", async () => { renderPanel(); expect(await screen.findByText("No agent work state.")).toBeTruthy(); }); it("renders an idle offline agent", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-1", name: "Planner", profileId: "codex", busy: { state: "idle" }, }, ], }); renderPanel(workState); expect(await screen.findByText("Planner")).toBeTruthy(); expect(screen.getByText("Offline")).toBeTruthy(); expect(screen.getByText("Idle")).toBeTruthy(); expect(screen.queryByLabelText("Planner tickets")).toBeNull(); }); it("renders a live busy agent with a short ticket", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-2", name: "Builder", profileId: "claude", live: { nodeId: "node-a", sessionId: "session-a", kind: "pty", }, busy: { state: "busy", ticket: "4c65d981-da5e-412d-85ed-3b9779b670fa", sinceMs: 123, }, }, ], }); renderPanel(workState); expect(await screen.findByText("Builder")).toBeTruthy(); expect(screen.getByText("Live")).toBeTruthy(); expect(screen.getByText("Busy")).toBeTruthy(); expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy(); }); it("renders an in-progress human ticket", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-4", name: "Implementer", profileId: "codex", busy: { state: "busy", ticket: "ticket-in-progress-abcdef", sinceMs: 789, }, tickets: [ { ticketId: "ticket-in-progress-abcdef", conversationId: "conversation-a", position: 0, status: "inProgress", source: { kind: "human" }, requesterLabel: "Anthony", taskPreview: "Wire the workstate queue UI", taskLen: 36, }, ], }, ], }); renderPanel(workState); const list = await screen.findByLabelText("Implementer tickets"); expect(within(list).getByText("#1 In progress")).toBeTruthy(); expect(within(list).getByText("Human")).toBeTruthy(); expect(within(list).getByText("Wire the workstate queue UI")).toBeTruthy(); expect(within(list).getByLabelText("ticket ticket-i")).toBeTruthy(); expect(within(list).getByLabelText("9 more characters")).toBeTruthy(); }); it("renders two tickets in FIFO order", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-5", name: "Reviewer", profileId: "claude", busy: { state: "idle" }, tickets: [ { ticketId: "ticket-second-abcdef", conversationId: "conversation-b", position: 1, status: "queued", source: { kind: "agent", agentId: "agent-main-123456" }, requesterLabel: "Main", taskPreview: "Then review the result", taskLen: 22, }, { ticketId: "ticket-first-abcdef", conversationId: "conversation-a", position: 0, status: "inProgress", source: { kind: "agent", agentId: "agent-qa-123456" }, requesterLabel: "QA", taskPreview: "Check behavior", taskLen: 14, }, ], }, ], }); renderPanel(workState); const list = await screen.findByLabelText("Reviewer tickets"); expect(within(list).getByText("QA (agent-qa)")).toBeTruthy(); expect(within(list).getByText("Main (agent-ma)")).toBeTruthy(); expect(list.textContent?.indexOf("#1 In progress")).toBeLessThan( list.textContent?.indexOf("#2 Queued") ?? -1, ); }); it("normalizes mock work state without tickets", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-6", name: "Legacy", profileId: "gemini", busy: { state: "idle" }, }, ], }); await expect(workState.getProjectWorkState(PROJECT_ID)).resolves.toEqual({ agents: [ { agentId: "agent-6", name: "Legacy", profileId: "gemini", busy: { state: "idle" }, tickets: [], }, ], conversations: [], }); }); it("renders conversation objective and summary joined by conversation id", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-8", name: "Summarizer", profileId: "codex", busy: { state: "busy", ticket: "ticket-goal-abcdef", sinceMs: 123 }, tickets: [ { ticketId: "ticket-goal-abcdef", conversationId: "conversation-goal", position: 0, status: "inProgress", source: { kind: "human" }, requesterLabel: "Anthony", taskPreview: "Continue implementation", taskLen: 23, }, { ticketId: "ticket-summary-abcdef", conversationId: "conversation-summary", position: 1, status: "queued", source: { kind: "agent", agentId: "agent-main-123456" }, requesterLabel: "Main", taskPreview: "Follow up", taskLen: 9, }, ], }, ], conversations: [ { conversationId: "conversation-summary", status: "ready", objectivePreview: null, summaryPreview: "Previous turn established the adapter boundary.", summaryLen: 47, upTo: "turn-2", recentTurns: [], }, { conversationId: "conversation-goal", status: "ready", objectivePreview: "Ship the Work panel summaries", summaryPreview: "This should be lower priority than the goal.", summaryLen: 48, upTo: "turn-1", recentTurns: [], }, ], }); renderPanel(workState); const list = await screen.findByLabelText("Summarizer tickets"); expect(within(list).getAllByText("Summary")).toHaveLength(2); expect( within(list).getByText("Goal: Ship the Work panel summaries"), ).toBeTruthy(); expect( within(list).getByText( "Previous turn established the adapter boundary.", ), ).toBeTruthy(); }); it("keeps partial and unavailable summaries compact without hiding tickets", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-9", name: "Contextualizer", profileId: "claude", busy: { state: "idle" }, tickets: [ { ticketId: "ticket-partial-abcdef", conversationId: "conversation-partial", position: 0, status: "inProgress", source: { kind: "agent", agentId: "agent-qa-123456" }, requesterLabel: "QA", taskPreview: "Validate partial context", taskLen: 24, }, { ticketId: "ticket-unavailable-abcdef", conversationId: "conversation-unavailable", position: 1, status: "queued", source: { kind: "human" }, requesterLabel: "Anthony", taskPreview: "Validate unavailable context", taskLen: 28, }, ], }, ], conversations: [ { conversationId: "conversation-partial", status: "partial", objectivePreview: null, summaryPreview: null, summaryLen: 0, upTo: null, recentTurns: [ { role: "prompt", source: { kind: "agent", agentId: "agent-qa-123456" }, atMs: 100, textPreview: "Only the latest prompt was available.", textLen: 37, }, ], }, { conversationId: "conversation-unavailable", status: "unavailable", objectivePreview: null, summaryPreview: "Summary service unavailable right now.", summaryLen: 38, upTo: null, recentTurns: [], }, ], }); renderPanel(workState); const list = await screen.findByLabelText("Contextualizer tickets"); expect(within(list).getByText("#1 In progress")).toBeTruthy(); expect(within(list).getByText("Validate partial context")).toBeTruthy(); expect(within(list).getByText("Partial")).toBeTruthy(); expect( within(list).getByText("Last: Only the latest prompt was available."), ).toBeTruthy(); expect(within(list).getByText("#2 Queued")).toBeTruthy(); expect(within(list).getByText("Validate unavailable context")).toBeTruthy(); expect(within(list).getByText("Unavailable")).toBeTruthy(); expect( within(list).getByText("Summary service unavailable right now."), ).toBeTruthy(); }); it("refreshes when a relevant domain event fires", async () => { const workState = new MockWorkStateGateway(); const system = new MockSystemGateway(); renderPanel(workState, system); await screen.findByText("No agent work state."); const spy = vi.spyOn(workState, "getProjectWorkState"); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-3", name: "Responder", profileId: "gemini", busy: { state: "busy", ticket: "ticket-123456789", sinceMs: 456 }, }, ], }); system.emit({ type: "agentBusyChanged", agentId: "agent-3", busy: true, }); expect(await screen.findByText("Responder")).toBeTruthy(); await waitFor(() => expect(spy).toHaveBeenCalled()); }); it("refreshes when delegationReady fires", async () => { const workState = new MockWorkStateGateway(); const system = new MockSystemGateway(); renderPanel(workState, system); await screen.findByText("No agent work state."); const spy = vi.spyOn(workState, "getProjectWorkState"); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-7", name: "Delegatee", profileId: "codex", busy: { state: "busy", ticket: "ticket-ready-123456", sinceMs: 123 }, tickets: [ { ticketId: "ticket-ready-123456", conversationId: "conversation-ready", position: 0, status: "inProgress", source: { kind: "agent", agentId: "agent-main-123456" }, requesterLabel: "Main", taskPreview: "Handle this delegated task", taskLen: 26, }, ], }, ], }); system.emit({ type: "delegationReady", agentId: "agent-7", ticket: "ticket-ready-123456", text: "Handle this delegated task", }); expect(await screen.findByText("Delegatee")).toBeTruthy(); expect(screen.getByText("#1 In progress")).toBeTruthy(); await waitFor(() => expect(spy).toHaveBeenCalled()); }); it("Open focuses a visible live cell and does not launch", async () => { const workState = new MockWorkStateGateway(); const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id; const host = document.createElement("div"); host.dataset.nodeId = leafId; document.body.appendChild(host); const launchSpy = vi.spyOn(agent, "launchAgent"); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-open", name: "Visible", profileId: "codex", live: { nodeId: leafId, sessionId: "session-open", kind: "pty" }, busy: { state: "idle" }, tickets: [], }, ], conversations: [], }); renderPanel(workState, new MockSystemGateway(), { layout, agent }); fireEvent.click(await screen.findByRole("button", { name: "Open" })); expect(host.style.outline).toContain("2px solid"); expect(launchSpy).not.toHaveBeenCalled(); host.remove(); }); it("Attach rebinds a hidden live agent to the deterministic empty cell", async () => { const workState = new MockWorkStateGateway(); const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const created = await agent.createAgent(PROJECT_ID, { name: "Hidden", profileId: "codex", }); await agent.launchAgent( PROJECT_ID, created.id, { cwd: "/x", rows: 24, cols: 80, nodeId: "hidden-node" }, () => {}, ); const attachSpy = vi.spyOn(agent, "attachLiveAgent"); const launchSpy = vi.spyOn(agent, "launchAgent"); const target = leaves(await layout.loadLayout(PROJECT_ID))[0].id; workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: created.id, name: "Hidden", profileId: "codex", live: { nodeId: "hidden-node", sessionId: "mock-agent-session-1", kind: "pty" }, busy: { state: "idle" }, tickets: [], }, ], conversations: [], }); renderPanel(workState, new MockSystemGateway(), { layout, agent }); const attach = await screen.findByRole("button", { name: "Attach" }); await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(false)); fireEvent.click(attach); await waitFor(() => expect(attachSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, target), ); await waitFor(async () => { const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0]; expect(leaf.agent).toBe(created.id); expect(leaf.session).toBe("mock-agent-session-1"); }); expect(launchSpy).not.toHaveBeenCalled(); }); it("disables Attach when no visible target cell exists", async () => { const workState = new MockWorkStateGateway(); const layout = new MockLayoutGateway(); const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id; await layout.mutateLayout(PROJECT_ID, { type: "setCellAgent", target: leafId, agent: "other-agent", }); await layout.mutateLayout(PROJECT_ID, { type: "setSession", target: leafId, session: "occupied-session", }); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-hidden", name: "Hidden", profileId: "codex", live: { nodeId: "hidden-node", sessionId: "session-hidden", kind: "pty" }, busy: { state: "idle" }, tickets: [], }, ], conversations: [], }); renderPanel(workState, new MockSystemGateway(), { layout, agent: new MockAgentGateway(), }); const attach = await screen.findByRole("button", { name: "Attach" }); await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(true)); expect(await screen.findByText("No empty visible cell.")).toBeTruthy(); }); it("Stop confirms with a busy warning, stops the live agent, and refreshes Work", async () => { const workState = new MockWorkStateGateway(); const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const created = await agent.createAgent(PROJECT_ID, { name: "Runner", profileId: "codex", }); const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id; await agent.launchAgent( PROJECT_ID, created.id, { cwd: "/x", rows: 24, cols: 80, nodeId: leafId }, () => {}, ); await layout.mutateLayout(PROJECT_ID, { type: "setCellAgent", target: leafId, agent: created.id, }); await layout.mutateLayout(PROJECT_ID, { type: "setSession", target: leafId, session: "mock-agent-session-1", }); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: created.id, name: "Runner", profileId: "codex", live: { nodeId: leafId, sessionId: "mock-agent-session-1", kind: "pty" }, busy: { state: "busy", ticket: "ticket-stop", sinceMs: 1 }, tickets: [ { ticketId: "ticket-stop", conversationId: "conv-stop", position: 0, status: "inProgress", source: { kind: "human" }, requesterLabel: "Human", taskPreview: "Do work", taskLen: 7, }, ], }, ], conversations: [], }); const stopSpy = vi.spyOn(agent, "stopLiveAgent"); const refreshSpy = vi.spyOn(workState, "getProjectWorkState"); const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); renderPanel(workState, new MockSystemGateway(), { layout, agent }); fireEvent.click(await screen.findByRole("button", { name: "Stop" })); await waitFor(() => expect(confirmSpy).toHaveBeenCalledWith( "Stop Runner? The current turn will be interrupted.", ), ); await waitFor(() => expect(stopSpy).toHaveBeenCalledWith(PROJECT_ID, created.id)); await waitFor(() => expect(refreshSpy).toHaveBeenCalled()); await waitFor(async () => { const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0]; expect(leaf.session).toBeNull(); }); confirmSpy.mockRestore(); }); it("View conversation exposes only Lot C preview fields", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-view", name: "Viewer", profileId: "codex", busy: { state: "idle" }, tickets: [ { ticketId: "ticket-view", conversationId: "conversation-view", position: 0, status: "queued", source: { kind: "human" }, requesterLabel: "Human", taskPreview: "Inspect preview", taskLen: 15, }, ], }, ], conversations: [ { conversationId: "conversation-view", status: "ready", objectivePreview: "Keep it compact", summaryPreview: "Only the exposed summary preview.", summaryLen: 33, upTo: "internal-turn-id", recentTurns: [ { role: "response", source: { kind: "agent", agentId: "agent-view" }, atMs: 42, textPreview: "Recent exposed turn.", textLen: 20, }, ], }, ], }); renderPanel(workState); const list = await screen.findByLabelText("Viewer tickets"); fireEvent.click(within(list).getByRole("button", { name: "View" })); expect(within(list).getByText("Goal:")).toBeTruthy(); expect(within(list).getByText("Keep it compact")).toBeTruthy(); expect(within(list).getByText("Summary:")).toBeTruthy(); expect( within(list).getByText("Only the exposed summary preview."), ).toBeTruthy(); expect(within(list).getByText("response:")).toBeTruthy(); expect(within(list).getByText("Recent exposed turn.")).toBeTruthy(); expect(list.textContent).not.toContain("internal-turn-id"); }); it("Copy summary copies only the exposed preview text", async () => { const writeText = vi.fn().mockResolvedValue(undefined); vi.stubGlobal("navigator", { clipboard: { writeText } }); const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-copy", name: "Copier", profileId: "codex", busy: { state: "idle" }, tickets: [ { ticketId: "ticket-copy", conversationId: "conversation-copy", position: 0, status: "queued", source: { kind: "human" }, requesterLabel: "Human", taskPreview: "Copy preview", taskLen: 12, }, ], }, ], conversations: [ { conversationId: "conversation-copy", status: "ready", objectivePreview: null, summaryPreview: "Copy this summary preview only.", summaryLen: 31, upTo: null, recentTurns: [ { role: "prompt", source: { kind: "human" }, atMs: 1, textPreview: "Do not copy this turn.", textLen: 22, }, ], }, ], }); renderPanel(workState); const list = await screen.findByLabelText("Copier tickets"); fireEvent.click(within(list).getByRole("button", { name: "Copy" })); await waitFor(() => expect(writeText).toHaveBeenCalledWith("Copy this summary preview only."), ); expect(await within(list).findByRole("button", { name: "Copied" })).toBeTruthy(); vi.unstubAllGlobals(); }); });