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

@ -288,30 +288,21 @@ describe("CustomAgentChatView", () => {
expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40");
});
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
it("restores a durable conversation without starting or sending to the provider (#366)", async () => {
const page = deferred<{
turns: Array<{ id: string; atMs: number; role: "prompt" | "response"; source: { kind: "human" } | { kind: "agent"; agentId: string }; text: string; textLen: number }>;
hasMore: boolean;
}>();
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "fresh-session",
transcriptUnavailableReason: "missingTranscript" as const,
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
transcriptUnavailableReason: "missingTranscript" as const,
})),
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onConversationId = vi.fn();
const conversation = { readPage: vi.fn(() => page.promise) };
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<DIProvider gateways={{ agent, conversation, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
@ -320,97 +311,81 @@ describe("CustomAgentChatView", () => {
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="orphan-conversation"
conversationId="saved-conversation"
onSessionId={vi.fn()}
onConversationId={onConversationId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith(
"project-1",
"agent-1",
expect.objectContaining({ conversationId: "orphan-conversation" }),
),
);
expect(
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
).toBeTruthy();
expect(screen.getByText("Sa transcription n’est plus disponible après la reprise.")).toBeTruthy();
expect(
screen.queryByText("Conversation liée — récupération du transcript en cours."),
).toBeNull();
expect(screen.queryByRole("progressbar")).toBeNull();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Délier la conversation" }));
expect(onConversationId).toHaveBeenCalledWith(null);
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
expect(await screen.findByText("Chargement de la conversation…")).toBeTruthy();
expect(screen.queryByText("Nouvelle conversation")).toBeNull();
page.resolve({
turns: [
{ id: "p1", atMs: 10, role: "prompt", source: { kind: "human" }, text: "Question sauvegardée", textLen: 20 },
{ id: "r1", atMs: 20, role: "response", source: { kind: "agent", agentId: "agent-1" }, text: "Réponse sauvegardée", textLen: 19 },
],
hasMore: false,
});
expect(await screen.findByText("Question sauvegardée")).toBeTruthy();
expect(screen.getByText("Réponse sauvegardée")).toBeTruthy();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(agent.reattachAgentChat).not.toHaveBeenCalled();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
});
it("keeps an empty linked custom CLI continuation usable without explicit transcript loss", async () => {
it("keeps a durable load failure distinct and retries without clearing its conversation id", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({ sessionId: "valid-session" })),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="valid-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
</DIProvider>,
);
expect(
await screen.findByText("Envoyez un message pour démarrer la conversation structurée."),
).toBeTruthy();
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
});
it("treats emptyConversation as a normal fresh conversation", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "fresh-session",
transcriptUnavailableReason: "emptyConversation" as const,
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
transcriptUnavailableReason: "emptyConversation" as const,
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="conversation-1" onSessionId={vi.fn()} onConversationId={vi.fn()} />
</DIProvider>,
);
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
});
it("maps NOT_FOUND to an actionable missing conversation without automatic looping", async () => {
const agent = {
launchAgentChat: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }),
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const conversation = {
readPage: vi.fn()
.mockRejectedValueOnce(new Error("disk temporarily unavailable"))
.mockResolvedValueOnce({
turns: [{ id: "p1", atMs: 10, role: "prompt" as const, source: { kind: "human" as const }, text: "Message restauré", textLen: 16 }],
hasMore: false,
}),
};
const onConversationId = vi.fn();
render(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="missing-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
<DIProvider gateways={{ agent, conversation, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="saved-conversation" onSessionId={vi.fn()} onConversationId={onConversationId} />
</DIProvider>,
);
expect(await screen.findByText("Cette conversation est introuvable dans le stockage disponible.")).toBeTruthy();
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/Impossible de charger cette conversation sauvegardée/)).toBeTruthy();
expect(onConversationId).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
expect(await screen.findByText("Message restauré")).toBeTruthy();
expect(conversation.readPage).toHaveBeenCalledTimes(2);
expect(onConversationId).not.toHaveBeenCalled();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
it("keeps a confirmed missing durable conversation selected until an explicit action", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const conversation = { readPage: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }) };
const onConversationId = vi.fn();
render(
<DIProvider gateways={{ agent, conversation, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="missing-conversation" onSessionId={vi.fn()} onConversationId={onConversationId} />
</DIProvider>,
);
expect(await screen.findByText(/Cette conversation sauvegardée est introuvable/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Nouvelle conversation" })).toBeTruthy();
expect(onConversationId).not.toHaveBeenCalled();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
@ -430,6 +405,7 @@ describe("CustomAgentChatView", () => {
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
<AnnouncementsProvider projectId="project-1">
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
@ -660,6 +636,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways}
>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
@ -708,6 +685,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways}
>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
@ -1510,7 +1488,17 @@ describe("CustomAgentChatView", () => {
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" },
];
const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>();
const conversation = {
readPage: vi.fn(async () => ({
turns: [
{ id: "p1", atMs: 1, role: "prompt" as const, source: { kind: "human" as const }, text: "Initial prompt", textLen: 14 },
{ id: "r1", atMs: 2, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Initial reply", textLen: 13 },
{ id: "p2", atMs: 3, role: "prompt" as const, source: { kind: "human" as const }, text: "Question during graph view", textLen: 26 },
{ id: "r2", atMs: 4, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Answer restored from replay", textLen: 27 },
],
hasMore: false,
})),
};
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi
@ -1518,8 +1506,7 @@ describe("CustomAgentChatView", () => {
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback,
}))
.mockImplementationOnce(() => secondReattach.promise),
})),
detachAgentChat: vi.fn(async () => {}),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
@ -1542,6 +1529,7 @@ describe("CustomAgentChatView", () => {
<DIProvider
gateways={{
agent,
conversation,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
@ -1553,17 +1541,11 @@ describe("CustomAgentChatView", () => {
first.unmount();
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
scrollback = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" },
{ kind: "userPrompt" as const, text: "Question during graph view" },
{ kind: "final" as const, content: "Answer restored from replay" },
];
render(
<DIProvider
gateways={{
agent,
conversation,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
@ -1575,13 +1557,10 @@ describe("CustomAgentChatView", () => {
expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
act(() => {
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
});
expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
expect(screen.getByText("Question during graph view")).toBeTruthy();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1);
});
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
@ -2172,7 +2151,53 @@ describe("CustomAgentChatView", () => {
expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy();
});
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
it("hides remembered announcements while no live session is attached (#366)", async () => {
const system = new MockSystemGateway();
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
<AnnouncementsProvider projectId="project-1">
<CustomAgentChatView
projectId="project-1"
agentId="main"
agentName="Main"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId={null}
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</AnnouncementsProvider>
</DIProvider>,
);
await act(async () => {
await Promise.resolve();
system.emit({
type: "agentAnnouncement",
projectId: "project-1",
requester: "main",
target: "architect",
ticketId: "delegated-1",
text: "ancienne commande shell",
atMs: 1,
});
});
expect(screen.queryByTestId("announcements-preview")).toBeNull();
expect(screen.queryByText("ancienne commande shell")).toBeNull();
});
it("falls back to durable history when a persisted live session is gone", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
@ -2181,21 +2206,22 @@ describe("CustomAgentChatView", () => {
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
const conversation = { readPage: vi.fn(async () => ({
turns: [{ id: "r1", atMs: 1, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Durable fallback", textLen: 16 }],
hasMore: false,
})) };
render(
<DIProvider
gateways={{
agent,
conversation,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
@ -2214,18 +2240,9 @@ describe("CustomAgentChatView", () => {
</DIProvider>,
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: "conversation-1",
nodeId: "node-1",
}),
);
await waitFor(() =>
expect(onSessionId).toHaveBeenCalledWith("chat-session-2"),
);
expect(await screen.findByText("Durable fallback")).toBeTruthy();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(onSessionId).toHaveBeenCalledWith(null);
expect(screen.queryByText(/structured session gone/)).toBeNull();
});
@ -5657,7 +5674,7 @@ describe("CustomAgentChatView", () => {
expect(composer.className).toContain("shrink-0");
});
it("falls back when Tauri surfaces structured session NOT_FOUND as a raw string", async () => {
it("falls back to durable history when Tauri reports a missing session as text", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
@ -5670,21 +5687,22 @@ describe("CustomAgentChatView", () => {
)
.mockRejectedValueOnce(
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
)
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
const conversation = { readPage: vi.fn(async () => ({
turns: [{ id: "r1", atMs: 1, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Historique durable", textLen: 18 }],
hasMore: false,
})) };
render(
<DIProvider
gateways={{
agent,
conversation,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
@ -5703,7 +5721,7 @@ describe("CustomAgentChatView", () => {
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Historique durable")).toBeTruthy();
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
1,
"69b57638-0ac7-4a79-8252-e0436a3265f6",
@ -5715,13 +5733,8 @@ describe("CustomAgentChatView", () => {
expect.any(Function),
{ projectId: "project-1", agentId: "agent-1" },
);
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
3,
"chat-session-2",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
@ -5751,6 +5764,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways}
>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
@ -6016,6 +6030,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways}
>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
@ -6057,6 +6072,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways}
>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
@ -6114,6 +6130,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways}
>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
@ -6420,7 +6437,7 @@ describe("CustomAgentChatView", () => {
render(
<DIProvider gateways={{
agent,
conversation: { readPage: vi.fn() },
conversation: { readPage: vi.fn(async () => ({ turns: history, hasMore: false })) },
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}>
<CustomAgentChatView
@ -6452,9 +6469,16 @@ describe("CustomAgentChatView", () => {
expect(agent.sendAgentChat).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue" } });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false),
);
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
expect(await screen.findByText("Conversation poursuivie")).toBeTruthy();
expect(await screen.findByText("New final")).toBeTruthy();
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
"resumed-session",
"Continue",
expect.any(Function),
));
});
it("uses /clean for a new conversation and disables conversation changes during a turn", async () => {