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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user