Files
IdeA/frontend/src/features/workstate/workstate.test.tsx
Blomios 9ee06c7ec9 fix(domain,application,infrastructure,backend,web-server,app-tauri,frontend): statut/LED projet indexé par projectId, source LiveSessions/LiveStateStore/ReconcileLiveState — la fin d'un agent dans un projet non actif repasse désormais la LED du projet au repos — domain: DomainEvent::AgentBusyChanged porte project_id ; infrastructure input: busy émis au démarrage du tour et idle au mark_idle avec le projet possesseur ; backend: DTO agentBusyChanged sérialise project_id (busy_changed_keeps_owning_project_on_the_wire) ; application workstate: ReconcileLiveState exécuté aussi avant les lectures de projection (plus seulement à l'ouverture) et liveness évaluée par (projectId, agentId) — un même agent_id vivant dans un autre projet n'épargne plus un orphelin ; web-server: ws_send_chat libère le busy avant le Final visible et la route work-state projet reste au contrat tauri ; app-tauri: commandes work-state/busy alignées sur le projectId possesseur ; frontend: useProjectActivity agrège l'activité par projectId (busy_changed filtré par projet possesseur, plus le seul projet courant), wsLiveClient/wsLiveClientReconnect propagant project_id, AnnouncementsProvider/CustomAgentChatView/WebWorkspaceLive/LayoutGrid.cellControlsLayering alignés — #373 (QA verte: cargo check --tests -p domain -p application -p infrastructure -p backend -p app-tauri -p web-server, cargo test -p application workstate::reconcile::tests, cargo test -p infrastructure input::tests::busy_event_fires_on_turn_start_and_idle_on_mark_idle, cargo test -p backend events::tests::busy_changed_keeps_owning_project_on_the_wire, cargo test -p web-server websocket_chat_send_releases_busy_before_visible_final + authorized_get_project_work_state_returns_tauri_contract, npm test -- useProjectActivity wsLiveClient wsLiveClientReconnect workstate AnnouncementsProvider CustomAgentChatView WebWorkspaceLive LayoutGrid.cellControlsLayering, npm run typecheck, cargo fmt --all -- --check, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)
2026-09-17 20:23:36 +02:00

1277 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
function renderPanel(
workState: MockWorkStateGateway = new MockWorkStateGateway(),
system: MockSystemGateway = new MockSystemGateway(),
overrides: Partial<Gateways> = {},
) {
const gateways = { workState, system, ...overrides } as unknown as Gateways;
return {
workState,
system,
...render(
<DIProvider gateways={gateways}>
<ProjectWorkStatePanel projectId={PROJECT_ID} />
</DIProvider>,
),
};
}
async function openAgentDetails(agentName: string): Promise<HTMLElement> {
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<HTMLElement> {
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(
<WorkStatePanelErrorBoundary>
<BrokenWorkPanel />
</WorkStatePanelErrorBoundary>,
);
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<Awaited<ReturnType<typeof agent.stopLiveAgent>>>();
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<Awaited<ReturnType<typeof agent.stopLiveAgent>>>();
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();
});
});