1277 lines
43 KiB
TypeScript
1277 lines
43 KiB
TypeScript
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();
|
||
});
|
||
});
|