feat(agent): reprise des sessions au redémarrage (B2) — commande + ResumeProjectPanel
- Tauri : commande list_resumable_agents (projectId) + ResumableAgentListDto
{ resumable } / ResumableAgentDto (camelCase, conversationId omis si None),
câblage state.rs (réutilise stores + ProfileStore, aucun nouveau port).
- Front : gateway listResumableAgents, ResumeProjectPanel monté à l'ouverture
de projet (opt-in, FR) : Reprendre (launch_agent nodeId+conversationId),
Nouvelle conversation (setCellConversation(null) puis launch), Ignorer,
Tout reprendre/ignorer. resumeSupported=false ⇒ « relance à neuf ».
- doc : §15.2 coquille agents→resumable (alignée use case/back/front).
Tests : app-tauri dto 9/9 ; vitest 324 (+10) ; workspace Rust 0 échec. 0 régression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
283
frontend/src/features/agents/resumeProject.test.tsx
Normal file
283
frontend/src/features/agents/resumeProject.test.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* B2 — reopen resume flow (ARCHITECTURE §15.2) wired to the stateful
|
||||
* `MockAgentGateway` / `MockLayoutGateway` via the real `DIProvider`.
|
||||
*
|
||||
* Covers both layers visible from the frontend:
|
||||
* - **Adapter** (`TauriAgentGateway.listResumableAgents`): invokes
|
||||
* `list_resumable_agents` with a top-level `{ projectId }` payload (NOT
|
||||
* `{ request: … }`) and unwraps `{ resumable }` → `ResumableAgent[]`.
|
||||
* - **Panel** (`ResumeProjectPanel`): renders nothing on an empty inventory;
|
||||
* lists the resumable agents (with their status) when non-empty.
|
||||
* - **Actions** (`useResumeProject`): Reprendre (with / without resume support),
|
||||
* Nouvelle conversation, Ignorer, Tout reprendre / Tout ignorer — each
|
||||
* asserting the exact `launchAgent` / `setCellConversation` calls.
|
||||
* - French labels on the main title + actions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockLayoutGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import type { ResumableAgent } from "@/domain";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter: TauriAgentGateway.listResumableAgents payload + unwrap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
Channel: class {},
|
||||
}));
|
||||
|
||||
describe("TauriAgentGateway.listResumableAgents (adapter)", () => {
|
||||
beforeEach(() => invokeMock.mockReset());
|
||||
|
||||
it("invokes list_resumable_agents with top-level projectId and unwraps { resumable }", async () => {
|
||||
const { TauriAgentGateway } = await import("@/adapters/agent");
|
||||
const wire: ResumableAgent[] = [
|
||||
{
|
||||
agentId: "a1",
|
||||
name: "Architect",
|
||||
nodeId: "n1",
|
||||
conversationId: "c1",
|
||||
wasRunning: true,
|
||||
resumeSupported: true,
|
||||
},
|
||||
];
|
||||
invokeMock.mockResolvedValueOnce({ resumable: wire });
|
||||
|
||||
const gw = new TauriAgentGateway();
|
||||
const out = await gw.listResumableAgents("proj-42");
|
||||
|
||||
// Command name + EXACT payload: top-level projectId, not wrapped in request.
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(invokeMock).toHaveBeenCalledWith("list_resumable_agents", {
|
||||
projectId: "proj-42",
|
||||
});
|
||||
const [, payload] = invokeMock.mock.calls[0];
|
||||
expect(payload).not.toHaveProperty("request");
|
||||
// Unwrapped to the bare array.
|
||||
expect(out).toEqual(wire);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel + actions: behind the real DIProvider with stateful mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECT_ID = "proj-resume-001";
|
||||
const CWD = "/home/me/proj";
|
||||
|
||||
function entry(over: Partial<ResumableAgent> = {}): ResumableAgent {
|
||||
return {
|
||||
agentId: "agent-1",
|
||||
name: "Architect",
|
||||
nodeId: "node-1",
|
||||
conversationId: "conv-1",
|
||||
wasRunning: true,
|
||||
resumeSupported: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(seed: ResumableAgent[]) {
|
||||
const agent = new MockAgentGateway();
|
||||
const layout = new MockLayoutGateway();
|
||||
agent._setResumableAgents(PROJECT_ID, seed);
|
||||
|
||||
// Spy on the launch/mutate flow so we assert the exact calls without relying
|
||||
// on the singleton/agent-existence checks of the stateful mock.
|
||||
const launchSpy = vi
|
||||
.spyOn(agent, "launchAgent")
|
||||
.mockResolvedValue({} as never);
|
||||
// Resolve the layout mutation: the seeded `nodeId`s are synthetic and don't
|
||||
// exist in the mock's default tree, so the real `applyOperation` would throw.
|
||||
const mutateSpy = vi
|
||||
.spyOn(layout, "mutateLayout")
|
||||
.mockResolvedValue({} as never);
|
||||
|
||||
const gateways = { agent, layout } as unknown as Gateways;
|
||||
const utils = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ResumeProjectPanel projectId={PROJECT_ID} cwd={CWD} />
|
||||
</DIProvider>,
|
||||
);
|
||||
return { agent, layout, launchSpy, mutateSpy, ...utils };
|
||||
}
|
||||
|
||||
describe("ResumeProjectPanel (panel mounting)", () => {
|
||||
it("renders nothing when the resumable inventory is empty", async () => {
|
||||
const { container } = renderPanel([]);
|
||||
// No async inventory ⇒ stays null. Give the effect a tick to settle.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("resume-project-panel")).toBeNull();
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the panel listing the resumable agents when non-empty", async () => {
|
||||
renderPanel([
|
||||
entry({ agentId: "a-run", name: "Architect", wasRunning: true }),
|
||||
entry({
|
||||
agentId: "a-closed",
|
||||
name: "Tester",
|
||||
nodeId: "node-2",
|
||||
wasRunning: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
expect(screen.getByTestId("resume-row-a-run")).toBeTruthy();
|
||||
expect(screen.getByTestId("resume-row-a-closed")).toBeTruthy();
|
||||
// Status derived from wasRunning.
|
||||
expect(screen.getByTestId("resume-status-a-run").textContent).toBe(
|
||||
"en cours",
|
||||
);
|
||||
expect(screen.getByTestId("resume-status-a-closed").textContent).toBe(
|
||||
"clôt",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses French labels for the title and global actions", async () => {
|
||||
renderPanel([entry()]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
// Title carries a non-breaking space before "?" (` `).
|
||||
expect(screen.getByText(/Reprendre les agents\s*\?/)).toBeTruthy();
|
||||
expect(screen.getByText("Tout reprendre")).toBeTruthy();
|
||||
expect(screen.getByText("Tout ignorer")).toBeTruthy();
|
||||
expect(screen.getByText("Nouvelle conversation")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResumeProjectPanel — per-agent actions", () => {
|
||||
it("Reprendre (resumeSupported + conversationId) → launchAgent with nodeId + conversationId", async () => {
|
||||
const { launchSpy } = renderPanel([
|
||||
entry({
|
||||
agentId: "a1",
|
||||
nodeId: "node-9",
|
||||
conversationId: "conv-9",
|
||||
resumeSupported: true,
|
||||
}),
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("resume Architect"));
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
const [projectId, agentId, options] = launchSpy.mock.calls[0];
|
||||
expect(projectId).toBe(PROJECT_ID);
|
||||
expect(agentId).toBe("a1");
|
||||
expect(options.nodeId).toBe("node-9");
|
||||
expect(options.conversationId).toBe("conv-9");
|
||||
// The button label is "Reprendre" when resume is supported.
|
||||
expect(screen.queryByText("Reprendre")).toBeNull(); // row drained after action
|
||||
});
|
||||
|
||||
it("Reprendre (resumeSupported === false) → 'relance à neuf' label + launch WITHOUT conversationId", async () => {
|
||||
const { launchSpy } = renderPanel([
|
||||
entry({
|
||||
agentId: "a2",
|
||||
nodeId: "node-7",
|
||||
conversationId: "conv-ignored",
|
||||
resumeSupported: false,
|
||||
name: "Legacy",
|
||||
}),
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
// The "relance à neuf" wording is shown for an unsupported profile.
|
||||
expect(screen.getByTestId("resume-fresh-note-a2").textContent).toContain(
|
||||
"relance à neuf",
|
||||
);
|
||||
// And the primary button reads "Relancer à neuf" (FR), not "Reprendre".
|
||||
const btn = screen.getByLabelText("resume Legacy");
|
||||
expect(btn.textContent).toBe("Relancer à neuf");
|
||||
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
const [, , options] = launchSpy.mock.calls[0];
|
||||
expect(options.nodeId).toBe("node-7");
|
||||
// Unsupported ⇒ NO conversation id passed (launches fresh).
|
||||
expect(options.conversationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Nouvelle conversation → setCellConversation(nodeId, null) then launch without conversationId", async () => {
|
||||
const { launchSpy, mutateSpy } = renderPanel([
|
||||
entry({ agentId: "a3", nodeId: "node-3", conversationId: "conv-3" }),
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("new conversation Architect"));
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
// setCellConversation cleared BEFORE the launch.
|
||||
expect(mutateSpy).toHaveBeenCalledWith(PROJECT_ID, {
|
||||
type: "setCellConversation",
|
||||
target: "node-3",
|
||||
conversationId: null,
|
||||
});
|
||||
const mutateOrder = mutateSpy.mock.invocationCallOrder[0];
|
||||
const launchOrder = launchSpy.mock.invocationCallOrder[0];
|
||||
expect(mutateOrder).toBeLessThan(launchOrder);
|
||||
// Launch carries no conversation id.
|
||||
const [, , options] = launchSpy.mock.calls[0];
|
||||
expect(options.conversationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Ignorer → drops the row locally, no launchAgent", async () => {
|
||||
const { launchSpy } = renderPanel([
|
||||
entry({ agentId: "a4", name: "Doomed" }),
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("ignore Doomed"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("resume-row-a4")).toBeNull(),
|
||||
);
|
||||
expect(launchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResumeProjectPanel — global actions", () => {
|
||||
it("Tout reprendre → launches every pending agent", async () => {
|
||||
const { launchSpy } = renderPanel([
|
||||
entry({ agentId: "a1", nodeId: "n1" }),
|
||||
entry({ agentId: "a2", nodeId: "n2", name: "Tester" }),
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("resume all agents"));
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(2));
|
||||
const launchedAgentIds = launchSpy.mock.calls.map((c) => c[1]).sort();
|
||||
expect(launchedAgentIds).toEqual(["a1", "a2"]);
|
||||
// Panel unmounts once the pending set drains.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("resume-project-panel")).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("Tout ignorer → no launch, panel unmounts", async () => {
|
||||
const { launchSpy } = renderPanel([
|
||||
entry({ agentId: "a1" }),
|
||||
entry({ agentId: "a2", name: "Tester" }),
|
||||
]);
|
||||
await screen.findByTestId("resume-project-panel");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("ignore all agents"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("resume-project-panel")).toBeNull(),
|
||||
);
|
||||
expect(launchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user