fix(domain,application,infrastructure,backend,app-tauri,frontend): Stop demande agent préserve la conversation ouverte — arrêt sans fermeture/clôture du chat, états cohérents après cancel sur les 3 adapters de session, cellule et workstate synchronisés — #343 (QA verte)

This commit is contained in:
2026-09-13 10:03:36 +02:00
parent a1e8a6466f
commit c37638ff24
26 changed files with 1210 additions and 232 deletions

View File

@ -3,7 +3,7 @@ import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import type { AgentProfile, ReplyChunk } from "@/domain";
import type { AgentProfile, CancelAgentChatResult, ReplyChunk } from "@/domain";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import { AnnouncementsProvider } from "@/features/announcements";
import { PluginRuntimeProvider } from "@/features/plugins";
@ -1562,7 +1562,23 @@ describe("CustomAgentChatView", () => {
});
it("cancels only the current request and keeps the structured session usable", async () => {
let resolveCancel: () => void = () => {};
let resolveCancel: (result: CancelAgentChatResult) => void = () => {};
let firstTurnChunk: (chunk: ReplyChunk) => void = () => {};
let rejectFirstTurn: (reason: unknown) => void = () => {};
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Worker",
profileId: "structured",
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
@ -1571,14 +1587,19 @@ describe("CustomAgentChatView", () => {
})),
sendAgentChat: vi
.fn()
.mockImplementationOnce(() => new Promise<void>(() => {}))
.mockImplementationOnce((_sessionId: string, _prompt: string, onChunk) => {
firstTurnChunk = onChunk;
return new Promise<void>((_resolve, reject) => {
rejectFirstTurn = reject;
});
})
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({ kind: "final", content: "Next answer" });
}),
stopLiveAgent: vi.fn(),
cancelAgentChat: vi.fn(
() =>
new Promise<void>((resolve) => {
new Promise<CancelAgentChatResult>((resolve) => {
resolveCancel = resolve;
}),
),
@ -1590,7 +1611,8 @@ describe("CustomAgentChatView", () => {
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
system,
workState,
} as unknown as Gateways}
>
<CustomAgentChatView
@ -1630,6 +1652,32 @@ describe("CustomAgentChatView", () => {
expect(hasProjectCliInteraction("project-1")).toBe(true);
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
act(() => {
firstTurnChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "turn",
stage: "started",
label: "Tour actif",
nativeEvent: "item.started",
},
});
});
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Worker",
profileId: "structured",
busy: { state: "busy", ticket: "turn-1", sinceMs: 1 },
tickets: [],
},
],
conversations: [],
});
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: true });
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
const stoppingLabel = await screen.findByText("Stopping...");
@ -1639,8 +1687,29 @@ describe("CustomAgentChatView", () => {
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
);
expect(agent.cancelAgentChat).toHaveBeenCalledTimes(1);
resolveCancel();
resolveCancel({ sessionId: "chat-session-1", outcome: "cancelled" });
await waitFor(() => expect(screen.getByText("Demande arrêtée.")).toBeTruthy());
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
act(() => {
firstTurnChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "turn",
stage: "completed",
label: "Fin tardive à ignorer",
nativeEvent: "item.completed",
},
});
firstTurnChunk({ kind: "final", content: "Réponse tardive à ignorer" });
rejectFirstTurn({ code: "PROCESS", message: "cancelled turn ended" });
});
await act(async () => Promise.resolve());
expect(screen.queryByText("Fin tardive à ignorer")).toBeNull();
expect(screen.queryByText("Réponse tardive à ignorer")).toBeNull();
expect(screen.queryByText(/cancelled turn ended/)).toBeNull();
expect(screen.getAllByText("Demande arrêtée.")).toHaveLength(1);
expect(hasProjectCliInteraction("project-1")).toBe(false);
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
@ -1663,6 +1732,74 @@ describe("CustomAgentChatView", () => {
expect(await screen.findByText("Next answer")).toBeTruthy();
});
it.each(["alreadyCancelled", "alreadyCompleted"] as const)(
"treats backend outcome %s as one already-stopped terminal result",
async (outcome) => {
let reattachedChunk: (chunk: ReplyChunk) => void = () => {};
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Worker",
profileId: "structured",
busy: { state: "busy", ticket: "turn-1", sinceMs: 1 },
tickets: [],
},
],
conversations: [],
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(
async (sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
reattachedChunk = onChunk;
return { sessionId, scrollback: [] };
},
),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => ({
sessionId: "chat-session-1",
outcome,
})),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{ agent, system, workState } as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
await waitFor(() =>
expect(screen.getAllByText("Demande déjà arrêtée.")).toHaveLength(1),
);
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy();
expect(agent.closeAgentChat).not.toHaveBeenCalled();
act(() => {
reattachedChunk({ kind: "final", content: "Late reattached answer" });
});
expect(screen.queryByText("Late reattached answer")).toBeNull();
},
);
it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => {
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
@ -5625,6 +5762,9 @@ describe("CustomAgentChatView", () => {
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
expect(await screen.findByText("Demande déjà arrêtée.")).toBeTruthy();
expect(screen.getAllByText("Demande déjà arrêtée.")).toHaveLength(1);
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy();
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(agent.cancelAgentChat).toHaveBeenCalledWith("gone-session");
expect(agent.stopLiveAgent).not.toHaveBeenCalled();