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, WorkStatePanelErrorBoundary, } from "./ProjectWorkStatePanel"; const PROJECT_ID = "project-work-state-test"; function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } 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( , ), }; } async function openAgentDetails(agentName: string): Promise { const name = await screen.findByText(agentName); const row = name.closest("li") as HTMLElement | null; expect(row).toBeTruthy(); const button = within(row!).queryByRole("button", { name: "Détails" }); if (button) fireEvent.click(button); return row!; } async function openBackgroundTasks(agentName: string): Promise { const row = await openAgentDetails(agentName); const toggle = within(row).getByRole("button", { name: /tâche.*de fond/i }); fireEvent.click(toggle); return await within(row).findByLabelText(`Tâches de fond de ${agentName}`); } describe("ProjectWorkStatePanel", () => { it("renders the empty state", async () => { renderPanel(); expect(await screen.findByText("Aucun état de travail d'agent.")).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("Hors ligne")).toBeTruthy(); expect(screen.queryByText("Inactif")).toBeNull(); expect(screen.queryByLabelText("Tickets de Planner")).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("Occupé")).toBeTruthy(); expect(screen.getByText("Ticket 4c65d981")).toBeTruthy(); }); it("projects stopping and stopped backend states for parent and descendant without orange", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-parent", name: "Main", profileId: "codex", runtimeState: "stopping", live: { nodeId: "node-main", sessionId: "session-main", kind: "pty" }, busy: { state: "busy", ticket: "stale-parent", sinceMs: 1 }, tickets: [], }, { agentId: "agent-child", name: "Child", profileId: "codex", runtimeState: "stopped", busy: { state: "busy", ticket: "stale-child", sinceMs: 1 }, tickets: [], }, ], conversations: [], }); renderPanel(workState); const mainRow = (await screen.findByText("Main")).closest("li")!; const childRow = screen.getByText("Child").closest("li")!; const stopping = within(mainRow).getByText("Arrêt en cours…"); const stopped = within(childRow).getByText("Arrêté"); expect(stopping.className).not.toContain("text-warning"); expect(stopped.className).not.toContain("text-warning"); expect(within(mainRow).queryByText("Occupé")).toBeNull(); expect(within(childRow).queryByText("Occupé")).toBeNull(); }); 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); expect(await screen.findByText("Wire the workstate queue UI")).toBeTruthy(); expect(screen.queryByLabelText("Tickets de Implementer")).toBeNull(); await openAgentDetails("Implementer"); const list = await screen.findByLabelText("Tickets de Implementer"); expect(within(list).getByText("#1 En cours")).toBeTruthy(); expect(within(list).getByText("Humain")).toBeTruthy(); expect(within(list).getByText("Wire the workstate queue UI")).toBeTruthy(); expect(within(list).getByLabelText("ticket ticket-i")).toBeTruthy(); expect(within(list).getByLabelText("9 caractères supplémentaires")).toBeTruthy(); }); it("keeps long ticket intent out of the default agent row", async () => { const longPreview = "Goal: inspect the entire repository and produce a verbose implementation summary"; const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-long", name: "Git", profileId: "codex", busy: { state: "busy", ticket: "ticket-long-abcdef", sinceMs: 789, }, tickets: [ { ticketId: "ticket-long-abcdef", conversationId: "conversation-long", position: 0, status: "inProgress", source: { kind: "human" }, requesterLabel: "Anthony", taskPreview: longPreview, taskLen: longPreview.length, }, ], }, ], }); renderPanel(workState); expect(await screen.findByText("Git")).toBeTruthy(); expect(screen.queryByText(longPreview)).toBeNull(); expect(screen.getByText("Goal: inspect the entire repository and p...")).toBeTruthy(); await openAgentDetails("Git"); const list = await screen.findByLabelText("Tickets de Git"); expect(within(list).getByText(longPreview)).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); expect(await screen.findByText("Reviewer")).toBeTruthy(); expect(screen.queryByLabelText("Tickets de Reviewer")).toBeNull(); await openAgentDetails("Reviewer"); const list = await screen.findByLabelText("Tickets de Reviewer"); expect(within(list).getByText("QA (agent-qa)")).toBeTruthy(); expect(within(list).getByText("Main (agent-ma)")).toBeTruthy(); expect(list.textContent?.indexOf("#1 En cours")).toBeLessThan( list.textContent?.indexOf("#2 En attente") ?? -1, ); }); it("normalizes the real backend background-task payload shape", async () => { // Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase, // `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted // when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs. // Headless rendezvous tasks may carry requester/target/conversation context. const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-bg", name: "Runner", profileId: "codex", busy: { state: "idle" }, backgroundTasks: [ { taskId: "task-waiting-1", kind: "command", state: "waiting", createdAtMs: 10, updatedAtMs: 11, }, { taskId: "task-queued-1", kind: "headlessRendezvous", state: "queued", requesterAgentId: "agent-main", targetAgentId: "agent-bg", conversationId: "conversation-rdv", createdAtMs: 12, updatedAtMs: 13, }, { taskId: "task-failed-1", kind: "command", state: "failed", exitCode: 1, summary: "process failed", stderrTail: "boom", createdAtMs: 14, updatedAtMs: 15, }, { taskId: "task-expired-1", kind: "sessionResume", state: "expired", createdAtMs: 16, updatedAtMs: 17, }, { taskId: "task-cancelling-1", kind: "command", state: "cancelling", createdAtMs: 18, updatedAtMs: 19, }, { taskId: "task-cancel-failed-1", kind: "command", state: "cancelFailed", summary: "kill denied", createdAtMs: 20, updatedAtMs: 21, }, ], }, ], }); // Adapter-level assertion: locks the mapping against the real payload. const state = await workState.getProjectWorkState(PROJECT_ID); const tasks = state.agents[0]?.backgroundTasks ?? []; expect(tasks.map((t) => [t.taskId, t.status])).toEqual([ ["task-waiting-1", "pending"], ["task-queued-1", "pending"], ["task-failed-1", "failed"], ["task-expired-1", "cancelled"], ["task-cancelling-1", "cancelling"], ["task-cancel-failed-1", "cancelFailed"], ]); // Optional fields omitted by the backend normalize to null (not undefined/NaN). expect(tasks[0]?.exitCode).toBeNull(); expect(tasks[0]?.stdoutTail).toBeNull(); expect(tasks[0]?.summary).toBeNull(); expect(tasks[2]?.stderrTail).toBe("boom"); // `summary` (ticket #5) is carried through when present. expect(tasks[2]?.summary).toBe("process failed"); expect(tasks[1]?.requesterAgentId).toBe("agent-main"); expect(tasks[1]?.targetAgentId).toBe("agent-bg"); expect(tasks[1]?.conversationId).toBe("conversation-rdv"); // updatedAtMs (backend-emitted) is carried through for chronological ordering. expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17, 19, 21]); renderPanel(workState); expect(await screen.findByText("Runner")).toBeTruthy(); expect(screen.getByText("6 bg")).toBeTruthy(); expect(screen.queryByText("6 tâches de fond (3 actives, 3 en échec)")).toBeNull(); expect(screen.queryByLabelText("Tâches de fond de Runner")).toBeNull(); const list = await openBackgroundTasks("Runner"); const cancelButtons = within(list).getAllByRole("button", { name: "Annuler" }); const retryButtons = within(list).getAllByRole("button", { name: "Réessayer" }); // waiting + queued + cancelling are cancellable; failed + expired + cancelFailed are retryable. expect(cancelButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3); expect(retryButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3); expect(list.textContent).toContain("Annulation…"); expect(list.textContent).toContain("Échec de l'annulation"); const rows = within(list).getAllByRole("listitem"); const rowTexts = rows.map((row) => row.textContent ?? ""); expect(rowTexts).toHaveLength(6); const cancelFailedRow = rows[0] as HTMLElement; const cancellingRow = rows[1] as HTMLElement; expect(within(cancellingRow).getByText("Annulation…")).toBeTruthy(); expect( (within(cancellingRow).getByRole("button", { name: "Annuler", }) as HTMLButtonElement).disabled, ).toBe(false); expect(within(cancelFailedRow).getByText("Échec de l'annulation")).toBeTruthy(); expect( (within(cancelFailedRow).getByRole("button", { name: "Réessayer", }) as HTMLButtonElement).disabled, ).toBe(false); // Rows are ordered most-recently-updated first (updatedAtMs desc), taskId as // the deterministic tiebreak — locks the fix for the dead finishedAtMs sort. expect(rowTexts[0]).toContain("Échec de l'annulation"); expect(rowTexts[1]).toContain("Annulation…"); expect(rowTexts[2]).toContain("Annulée"); expect(rowTexts[3]).toContain("Échec"); expect(rowTexts[4]).toContain("En attente"); expect(rowTexts[5]).toContain("En attente"); }); it("shows a terminal task summary and hides it when absent or non-terminal (#5)", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-summary", name: "Reporter", profileId: "codex", busy: { state: "idle" }, backgroundTasks: [ { taskId: "run-with-summary", kind: "command", state: "running", summary: "should-not-appear-while-running", createdAtMs: 40, updatedAtMs: 40, }, { taskId: "done-with-summary", kind: "command", state: "completed", summary: "Build succeeded in 12s", createdAtMs: 30, updatedAtMs: 30, }, { taskId: "fail-with-summary", kind: "command", state: "failed", exitCode: 1, summary: "Compile error: missing semicolon", createdAtMs: 20, updatedAtMs: 20, }, { taskId: "done-no-summary", kind: "command", state: "completed", createdAtMs: 10, updatedAtMs: 10, }, ], }, ], }); renderPanel(workState); expect(await screen.findByText("Reporter")).toBeTruthy(); expect(screen.getByText("4 bg")).toBeTruthy(); expect(screen.queryByText("4 tâches de fond (1 active, 1 en échec)")).toBeNull(); expect(screen.queryByLabelText("Tâches de fond de Reporter")).toBeNull(); const list = await openBackgroundTasks("Reporter"); // Terminal tasks with a summary render it discreetly. expect( within(list).getByLabelText("résumé de la tâche done-wit").textContent, ).toBe("Build succeeded in 12s"); expect( within(list).getByText("Compile error: missing semicolon"), ).toBeTruthy(); // A completed task without a summary shows no summary line. expect(within(list).queryByLabelText("résumé de la tâche done-no-")).toBeNull(); // A still-running task never shows its (non-terminal) summary. expect(within(list).queryByLabelText("résumé de la tâche run-with")).toBeNull(); expect( within(list).queryByText("should-not-appear-while-running"), ).toBeNull(); }); it("attaches, repaints, streams, and detaches live background task output", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-live-task", name: "Runner", profileId: "codex", busy: { state: "idle" }, backgroundTasks: [ { taskId: "task-live-output", kind: "command", state: "running", createdAtMs: 10, updatedAtMs: 10, }, ], }, ], }); workState._setBackgroundTaskAttachment("task-live-output", { scrollback: "already printed\n", live: true, }); const attachSpy = vi.spyOn(workState, "attachBackgroundTask"); const view = renderPanel(workState); const list = await openBackgroundTasks("Runner"); fireEvent.click(within(list).getByRole("button", { name: "Direct" })); expect(attachSpy).toHaveBeenCalledWith("task-live-output", expect.any(Function)); expect( (await within(list).findByLabelText("sortie directe de la tâche task-liv")).textContent, ).toContain("already printed"); expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1); workState._emitBackgroundTaskOutput("task-live-output", "new line\n"); await waitFor(() => expect( within(list).getByLabelText("sortie directe de la tâche task-liv").textContent, ).toContain("new line"), ); fireEvent.click(within(list).getByRole("button", { name: "Détacher" })); await waitFor(() => expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0), ); expect(within(list).queryByLabelText("sortie directe de la tâche task-liv")).toBeNull(); fireEvent.click(within(list).getByRole("button", { name: "Direct" })); await within(list).findByLabelText("sortie directe de la tâche task-liv"); expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1); view.unmount(); expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0); }); it("renders a legacy agent without tickets", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-6", name: "Legacy", profileId: "gemini", busy: { state: "idle" }, }, ], }); renderPanel(workState); expect(await screen.findByText("Legacy")).toBeTruthy(); expect(screen.getByText("Hors ligne")).toBeTruthy(); expect(screen.queryByText("Inactif")).toBeNull(); expect(screen.queryByLabelText("Tickets de Legacy")).toBeNull(); }); it("ignores a stray top-level backgroundTasks array (nested tasks intact) (#5)", async () => { // The backend nests tasks under their agent and emits no `ownerAgentId`; a // project-root `backgroundTasks` array is not part of the contract and must // not be merged in (dead path removed in ticket #5). const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-nested", name: "Nested", profileId: "codex", busy: { state: "idle" }, backgroundTasks: [ { taskId: "nested-1", kind: "command", state: "completed", createdAtMs: 1, updatedAtMs: 2, }, ], }, ], backgroundTasks: [ { taskId: "orphan-top-level", kind: "command", state: "completed", ownerAgentId: "agent-nested", createdAtMs: 3, updatedAtMs: 4, }, ], }); const state = await workState.getProjectWorkState(PROJECT_ID); expect((state.agents[0]?.backgroundTasks ?? []).map((t) => t.taskId)).toEqual( ["nested-1"], ); }); it("normalizes missing top-level work state arrays", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, {}); await expect(workState.getProjectWorkState(PROJECT_ID)).resolves.toEqual({ agents: [], conversations: [], }); }); it("renders an error fallback instead of blanking the app when Work throws", () => { function BrokenWorkPanel(): JSX.Element { throw new Error("render exploded"); } render( , ); expect(screen.getByRole("alert").textContent).toContain( "Le panneau Travail n'a pas pu s'afficher.", ); expect(screen.getByText("render exploded")).toBeTruthy(); expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); }); 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); expect(await screen.findByText("Continue implementation")).toBeTruthy(); expect(screen.queryByLabelText("Tickets de Summarizer")).toBeNull(); expect(screen.queryByText("Objectif : Ship the Work panel summaries")).toBeNull(); expect( screen.queryByText("Previous turn established the adapter boundary."), ).toBeNull(); expect(screen.queryByRole("button", { name: "Afficher" })).toBeNull(); expect(screen.queryByRole("button", { name: "Copier" })).toBeNull(); await openAgentDetails("Summarizer"); const list = await screen.findByLabelText("Tickets de Summarizer"); expect(within(list).getAllByText("Résumé")).toHaveLength(2); expect( within(list).getByText("Objectif : 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); expect(await screen.findByText("Validate partial context")).toBeTruthy(); expect(screen.queryByLabelText("Tickets de Contextualizer")).toBeNull(); await openAgentDetails("Contextualizer"); const list = await screen.findByLabelText("Tickets de Contextualizer"); expect(within(list).getByText("#1 En cours")).toBeTruthy(); expect(within(list).getByText("Validate partial context")).toBeTruthy(); expect(within(list).getByText("Partiel")).toBeTruthy(); expect( within(list).getByText("Dernier : Only the latest prompt was available."), ).toBeTruthy(); expect(within(list).getByText("#2 En attente")).toBeTruthy(); expect(within(list).getByText("Validate unavailable context")).toBeTruthy(); expect(within(list).getByText("Indisponible")).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("Aucun état de travail d'agent."); 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", projectId: PROJECT_ID, 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("Aucun état de travail d'agent."); 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(); await openAgentDetails("Delegatee"); expect(screen.getByText("#1 En cours")).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 }); await openAgentDetails("Visible"); fireEvent.click(await screen.findByRole("button", { name: "Ouvrir" })); 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 }); await openAgentDetails("Hidden"); const attach = await screen.findByRole("button", { name: "Attacher" }); 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(), }); await openAgentDetails("Hidden"); const attach = await screen.findByRole("button", { name: "Attacher" }); await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(true)); expect(await screen.findByText("Aucune cellule visible vide.")).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 pendingStop = deferred>>(); const stopSpy = vi .spyOn(agent, "stopLiveAgent") .mockReturnValue(pendingStop.promise); const refreshSpy = vi.spyOn(workState, "getProjectWorkState"); const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); renderPanel(workState, new MockSystemGateway(), { layout, agent }); await openAgentDetails("Runner"); const row = await openAgentDetails("Runner"); fireEvent.click(within(row).getByRole("button", { name: "Arrêter" })); await waitFor(() => expect(confirmSpy).toHaveBeenCalledWith( "Arrêter Runner ? Le tour en cours sera interrompu.", ), ); await waitFor(() => expect(stopSpy).toHaveBeenCalledWith( PROJECT_ID, created.id, "Arrêt demandé depuis Travail", ), ); const stopping = within(row).getByRole("button", { name: "Arrêt en cours…" }); expect((stopping as HTMLButtonElement).disabled).toBe(true); expect(within(row).getAllByText("Arrêt en cours…")).toHaveLength(2); expect(within(row).queryByText("Occupé")).toBeNull(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: created.id, name: "Runner", profileId: "codex", runtimeState: "stopped", busy: { state: "idle" }, tickets: [], }, ], conversations: [], }); pendingStop.resolve({ agentId: created.id, alreadyStopped: false, stopped: [ { sessionId: "mock-agent-session-1", nodeId: leafId, kind: "pty", runtimeState: "stopped", }, ], }); await waitFor(() => expect(refreshSpy).toHaveBeenCalled()); await waitFor(() => expect(within(row).getAllByText("Arrêté")).toHaveLength(1)); expect(within(row).queryByText("Occupé")).toBeNull(); await waitFor(async () => { const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0]; expect(leaf.session).toBeNull(); }); confirmSpy.mockRestore(); }); it("surfaces an incomplete stop and retries without hiding the backend state", async () => { const workState = new MockWorkStateGateway(); const agent = new MockAgentGateway(); const pendingRetry = deferred>>(); workState._setProjectWorkState(PROJECT_ID, { agents: [ { agentId: "agent-partial", name: "Main", profileId: "codex", live: { nodeId: "node-main", sessionId: "session-main", kind: "pty" }, busy: { state: "busy", ticket: "ticket-main", sinceMs: 1 }, tickets: [], }, ], conversations: [], }); const stopSpy = vi .spyOn(agent, "stopLiveAgent") .mockRejectedValueOnce(new Error("cascade failed")) .mockReturnValueOnce(pendingRetry.promise); const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); renderPanel(workState, new MockSystemGateway(), { agent }); const row = await openAgentDetails("Main"); fireEvent.click(within(row).getByRole("button", { name: "Arrêter" })); expect(await within(row).findByText("Arrêt incomplet")).toBeTruthy(); expect(within(row).getByRole("alert").textContent).toBe( "Certains travaux sont encore actifs.", ); fireEvent.click(within(row).getByRole("button", { name: "Réessayer l’arrêt" })); await waitFor(() => expect(stopSpy).toHaveBeenCalledTimes(2)); expect(confirmSpy).toHaveBeenCalledTimes(1); expect( (within(row).getByRole("button", { name: "Arrêt en cours…" }) as HTMLButtonElement) .disabled, ).toBe(true); pendingRetry.resolve({ agentId: "agent-partial", alreadyStopped: true, stopped: [] }); await waitFor(() => expect(within(row).getAllByText("Arrêté")).toHaveLength(2)); 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); await openAgentDetails("Viewer"); const list = await screen.findByLabelText("Tickets de Viewer"); fireEvent.click(within(list).getByRole("button", { name: "Afficher" })); const details = within(list).getByLabelText( "détails de la conversation conversation-view", ); expect(within(details).getByText(/^Objectif/)).toBeTruthy(); expect(within(details).getByText("Keep it compact")).toBeTruthy(); expect(within(details).getByText(/^Résumé/)).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); await openAgentDetails("Copier"); const list = await screen.findByLabelText("Tickets de Copier"); fireEvent.click(within(list).getByRole("button", { name: "Copier" })); await waitFor(() => expect(writeText).toHaveBeenCalledWith("Copy this summary preview only."), ); expect(await within(list).findByRole("button", { name: "Copié" })).toBeTruthy(); vi.unstubAllGlobals(); }); });