fix(frontend): restaure l'historique durable au retour sur une conversation existante sans session live — freshOnOpen réservé au reset explicite (retiré de LayoutGrid et WebAgentCell), conversationId persistant de la cellule réutilisé au remontage ordinaire avec chargement de l'historique via conversation.readPage (machine savedConversationLoadState idle/loading/loaded/error/notFound, pagination ancrée conservée, écran vide faussement « Nouvelle conversation » éliminé), bannière d'annonces live et fallback de délégation masqués sans session réellement rattachée (liveSessionAttached), /clean et le reset ouvrent explicitement une nouvelle conversation (conversationId: null), erreurs de chargement traitées avec réessai et bouton « Nouvelle conversation » à la place du déliage, composer et pièces jointes désactivés en échec de chargement — #366 (QA verte: npm test ciblé CustomAgentChatView + LayoutGrid.chat + WebAgentCell 3 fichiers 119 tests OK, npm run build OK, git diff --check OK; warning jsdom/canvas connu non bloquant, npm audit: vulnérabilités préexistantes non liées)

This commit is contained in:
2026-09-17 08:51:26 +02:00
parent 5afb2b1c65
commit e118a34c64
6 changed files with 338 additions and 229 deletions

View File

@ -47,6 +47,7 @@ import type { AgentProfile } from "@/domain";
import type { Gateways } from "@/ports";
import {
MockAgentGateway,
MockConversationGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
@ -97,6 +98,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([profile]);
const created = await agent.createAgent("p1", {
name: "Worker",
@ -116,6 +118,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
terminal,
system,
workState,
conversation,
} as unknown as Gateways;
}
@ -175,6 +178,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "hello there" },
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false),
);
fireEvent.click(screen.getByText("Envoyer"));
await waitFor(() => expect(screen.getByText("Terminé")).toBeTruthy());
@ -204,13 +210,14 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("restores a project custom CLI cell after switching away and back without rewriting durable ids", async () => {
it("restores durable messages after project A → B → A without starting a provider", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([structuredProfile]);
const agentA = await agent.createAgent("p1", {
@ -229,6 +236,15 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
target: leafA.id,
preferredView: "chat",
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafA.id,
conversationId: "saved-a",
});
conversation._setTurns("p1", "saved-a", [
{ id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "Question projet A", textLen: 17 },
{ id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: agentA.id }, text: "Réponse projet A", textLen: 16 },
]);
const agentB = await agent.createAgent("p2", {
name: "Worker B",
@ -249,17 +265,14 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
terminal,
system,
workState,
conversation,
} as unknown as Gateways;
const launchChat = vi.spyOn(agent, "launchAgentChat");
const view = renderGrid(gateways, "p1");
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0];
expect(leaf.preferredView).toBe("chat");
expect(leaf.session).toMatch(/^mock-agent-chat-/);
expect(leaf.conversationId).toMatch(/^mock-conversation-mock-agent-chat-/);
});
expect(await screen.findByText("Question projet A")).toBeTruthy();
expect(screen.getByText("Réponse projet A")).toBeTruthy();
const persistedA = leaves(await layout.loadLayout("p1"))[0];
view.rerender(
@ -280,10 +293,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
.toBeNull();
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
expect(await screen.findByText("Question projet A")).toBeTruthy();
expect(screen.getByText("Réponse projet A")).toBeTruthy();
const restoredA = leaves(await layout.loadLayout("p1"))[0];
expect(restoredA.preferredView).toBe("chat");
expect(restoredA.session).toBe(persistedA.session);
expect(restoredA.conversationId).toBe(persistedA.conversationId);
expect(launchChat).not.toHaveBeenCalled();
});
it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => {
@ -343,7 +359,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(sendAgentChat).not.toHaveBeenCalled();
});
it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => {
it("does not render requester announcements without a live attached session (#366)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const workState = gateways.workState as MockWorkStateGateway;
@ -386,13 +402,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
const view = renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Worker attend Architect")).toBeTruthy();
expect(screen.queryByText("Worker attend Architect")).toBeNull();
view.unmount();
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Worker attend Architect")).toBeTruthy();
expect(screen.queryByText("Worker attend Architect")).toBeNull();
});
it("persists custom CLI composer height per layout cell", async () => {
@ -438,7 +454,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
}
});
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {
it("does not attach an unrelated structured background session implicitly (#169/#366)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
@ -476,17 +492,18 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
await waitFor(() => expect(launchChat).toHaveBeenCalled());
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
expect(launchChat).not.toHaveBeenCalled();
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
});
it("starts a fresh custom CLI conversation when returning without a live session (#363)", async () => {
it("restores a saved custom CLI conversation when returning without a live session (#366)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
@ -505,6 +522,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
conversationId: "conversation-known",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
conversation._setTurns("p1", "conversation-known", [
{ id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "known prompt", textLen: 12 },
{ id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: created.id }, text: "known response", textLen: 14 },
]);
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
sessionId: "chat-fresh",
cellKind: "chat" as const,
@ -521,27 +542,17 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
profile: profileGateway,
terminal,
system,
conversation,
} as unknown as Gateways);
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
expect(screen.queryByText("known prompt")).toBeNull();
expect(launchChat).toHaveBeenCalledWith(
"p1",
created.id,
expect.objectContaining({
nodeId: leafId,
conversationId: undefined,
}),
);
expect(reattachChat).toHaveBeenCalledWith("chat-fresh", expect.any(Function));
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0];
expect(leaf.conversationId).toBe("conversation-fresh");
expect(leaf.session).toBe("chat-fresh");
});
expect(screen.getByText("Envoyez un message pour démarrer la conversation structurée."))
.toBeTruthy();
expect(await screen.findByText("known prompt")).toBeTruthy();
expect(screen.getByText("known response")).toBeTruthy();
expect(screen.queryByText("Nouvelle conversation")).toBeNull();
expect(launchChat).not.toHaveBeenCalled();
expect(reattachChat).not.toHaveBeenCalled();
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].conversationId).toBe("conversation-known");
expect(leaves(updated)[0].session).toBeNull();
});
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
@ -646,6 +657,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Start live chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
@ -701,6 +716,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
);
expect(stopLive).not.toHaveBeenCalled();
expect(closeTerminal).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Start structured chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(launchChat).toHaveBeenCalled());
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
await waitFor(async () => {