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

@ -226,7 +226,15 @@ describe("TauriAgentGateway invoke payloads", () => {
});
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
await new TauriAgentGateway().cancelAgentChat("chat-session-1");
const response = {
sessionId: "chat-session-1",
outcome: "cancelled" as const,
};
invoke.mockResolvedValueOnce(response);
await expect(
new TauriAgentGateway().cancelAgentChat("chat-session-1"),
).resolves.toEqual(response);
expect(invoke).toHaveBeenCalledWith("cancel_agent_chat", {
sessionId: "chat-session-1",

View File

@ -17,6 +17,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
import type {
Agent,
AgentContextDocument,
CancelAgentChatResult,
EffortSelection,
GatewayError,
ExecuteSlashCommandResult,
@ -364,8 +365,8 @@ export class TauriAgentGateway implements AgentGateway {
await invoke("close_agent_session", { sessionId });
}
async cancelAgentChat(sessionId: string): Promise<void> {
await invoke("cancel_agent_chat", { sessionId });
async cancelAgentChat(sessionId: string): Promise<CancelAgentChatResult> {
return invoke<CancelAgentChatResult>("cancel_agent_chat", { sessionId });
}
async reattach(

View File

@ -308,11 +308,16 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
baseUrl: "https://host",
fetchImpl: async (url, init) => {
calls.push({ url, init });
const command = JSON.parse((init as { body: string }).body).command;
const response =
command === "cancel_agent_chat"
? { sessionId: "chat-1", outcome: "cancelled" }
: undefined;
return {
ok: true,
status: 200,
json: async () => undefined,
text: async () => "",
json: async () => response,
text: async () => response ? JSON.stringify(response) : "",
};
},
}),
@ -402,7 +407,10 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
});
expect(chunks).toEqual([{ kind: "final", content: "Hello back" }]);
await gw.cancelAgentChat("chat-1");
await expect(gw.cancelAgentChat("chat-1")).resolves.toEqual({
sessionId: "chat-1",
outcome: "cancelled",
});
await gw.closeAgentChat("chat-1");
await gw.detachAgentChat("chat-1");

View File

@ -23,6 +23,7 @@ import type {
Agent,
AgentContextDocument,
AppExitWorkGuardState,
CancelAgentChatResult,
DomainEvent,
EffortSelection,
ExecuteSlashCommandResult,
@ -469,8 +470,8 @@ export class HttpAgentGateway implements AgentGateway {
await this.http.invoke("close_agent_session", { sessionId });
}
async cancelAgentChat(sessionId: string): Promise<void> {
await this.http.invoke("cancel_agent_chat", { sessionId });
async cancelAgentChat(sessionId: string): Promise<CancelAgentChatResult> {
return this.http.invoke<CancelAgentChatResult>("cancel_agent_chat", { sessionId });
}
}

View File

@ -9,6 +9,7 @@ import type {
AgentDrift,
AppExitWorkGuardState,
AgentProfile,
CancelAgentChatResult,
DiagnosticWarning,
DomainEvent,
EffortSelection,
@ -1133,13 +1134,14 @@ export class MockAgentGateway implements AgentGateway {
}
}
async cancelAgentChat(sessionId: string): Promise<void> {
async cancelAgentChat(sessionId: string): Promise<CancelAgentChatResult> {
if (!this.chatScrollback.has(sessionId)) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} not found`,
} as GatewayError;
}
return { sessionId, outcome: "cancelled" };
}
async reattach(

View File

@ -1937,6 +1937,17 @@ export interface ExecuteSlashCommandResult {
effect: SlashCommandEffect;
}
/** Idempotent outcome of cancelling the current turn of a structured chat. */
export type CancelAgentChatOutcome =
| "cancelled"
| "alreadyCancelled"
| "alreadyCompleted";
export interface CancelAgentChatResult {
sessionId: string;
outcome: CancelAgentChatOutcome;
}
// ---------------------------------------------------------------------------
// Paired devices + pairing code (ticket #77)
// ---------------------------------------------------------------------------

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();

View File

@ -1009,6 +1009,7 @@ export function CustomAgentChatView({
>(null);
const [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false);
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
@ -1032,6 +1033,11 @@ export function CustomAgentChatView({
const draftSaveSeqRef = useRef(0);
const interactionSeqRef = useRef(0);
const activeInteractionIdRef = useRef<string | null>(null);
const cancelledInteractionIdsRef = useRef(new Set<string>());
const ignoreUnscopedChunksAfterStopRef = useRef(false);
const stopInFlightRef = useRef(false);
const stopTerminalOverrideRef = useRef(false);
const requesterRefreshSeqRef = useRef(0);
const draftHydratedRef = useRef(false);
const draftEditedBeforeHydrationRef = useRef(false);
const lastPersistedDraftRef = useRef("");
@ -1185,18 +1191,24 @@ export function CustomAgentChatView({
busy ||
stopping ||
Boolean(restart && restart.outcome !== "failed") ||
requesterDelegation.currentAgentBusy ||
delegationActive;
(!stopTerminalOverride &&
(requesterDelegation.currentAgentBusy || delegationActive));
const delegationFallbackText = requesterDelegation.delegationTargetName
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
: tr("Délégation en cours.", "Delegation in progress.");
const refreshRequesterDelegation = useCallback(async () => {
const refreshSeq = ++requesterRefreshSeqRef.current;
if (!workState) {
if (refreshSeq !== requesterRefreshSeqRef.current) return;
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
if (stopTerminalOverrideRef.current) {
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
}
return;
}
try {
@ -1214,11 +1226,17 @@ export function CustomAgentChatView({
ticket.source.agentId === agentId,
),
);
if (refreshSeq !== requesterRefreshSeqRef.current) return;
setRequesterDelegation({
currentAgentBusy,
delegationTargetName: target ? workAgentDisplayName(target) : null,
});
if (stopTerminalOverrideRef.current && !currentAgentBusy && !target) {
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
}
} catch {
if (refreshSeq !== requesterRefreshSeqRef.current) return;
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
@ -1446,7 +1464,20 @@ export function CustomAgentChatView({
}, []);
const receive = useCallback(
(chunk: ReplyChunk) => {
(chunk: ReplyChunk, sourceInteractionId?: string) => {
if (
sourceInteractionId &&
cancelledInteractionIdsRef.current.has(sourceInteractionId)
) {
return;
}
if (!sourceInteractionId && ignoreUnscopedChunksAfterStopRef.current) {
if (chunk.kind === "userPrompt") {
ignoreUnscopedChunksAfterStopRef.current = false;
} else {
return;
}
}
setTranscriptUnavailableReason(null);
scrollbackChunkCountRef.current += 1;
const activeInteractionId = activeInteractionIdRef.current;
@ -2284,6 +2315,9 @@ export function CustomAgentChatView({
const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe";
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
ignoreUnscopedChunksAfterStopRef.current = false;
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
activeInteractionIdRef.current = interactionId;
markProjectCliInteractionStarted(projectId, interactionId);
const finishInteraction = () => {
@ -2312,8 +2346,10 @@ export function CustomAgentChatView({
}));
const sendTurn = (sid: string) =>
attachmentInputs.length > 0
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
: agent.sendAgentChat!(sid, text, receive);
? agent.sendAgentChat!(sid, text, (chunk) => receive(chunk, interactionId), {
attachments: attachmentInputs,
})
: agent.sendAgentChat!(sid, text, (chunk) => receive(chunk, interactionId));
try {
const sid =
currentSession ??
@ -2322,8 +2358,10 @@ export function CustomAgentChatView({
retryAttachNotFound: true,
}));
await sendTurn(sid);
if (cancelledInteractionIdsRef.current.has(interactionId)) return;
finishInteraction();
} catch (e) {
if (cancelledInteractionIdsRef.current.has(interactionId)) return;
if (isNotFound(e)) {
try {
setCurrentSession(null);
@ -2361,11 +2399,31 @@ export function CustomAgentChatView({
async function stopCurrentRequest() {
const sid = sessionRef.current;
if (!sid || !agent.cancelAgentChat || stopping) return;
if (
!sid ||
!agent.cancelAgentChat ||
stopping ||
stopInFlightRef.current ||
stopTerminalOverrideRef.current
) {
return;
}
stopInFlightRef.current = true;
ignoreUnscopedChunksAfterStopRef.current = true;
const interactionBeingCancelled = activeInteractionIdRef.current;
if (interactionBeingCancelled) {
cancelledInteractionIdsRef.current.add(interactionBeingCancelled);
}
setStopping(true);
setError(null);
try {
await agent.cancelAgentChat(sid);
const settleStoppedRequest = (alreadyStopped: boolean) => {
if (stopTerminalOverrideRef.current) return;
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
@ -2380,32 +2438,28 @@ export function CustomAgentChatView({
...completed.turns,
{
role: "tool",
label: tr("Demande arrêtée.", "Request stopped."),
label: alreadyStopped
? tr("Demande déjà arrêtée.", "Request already stopped.")
: tr("Demande arrêtée.", "Request stopped."),
receivedAtMs: null,
},
],
};
});
void refreshRequesterDelegation();
};
try {
const result = await agent.cancelAgentChat(sid);
settleStoppedRequest(result.outcome !== "cancelled");
} catch (e) {
if (isNotFound(e)) {
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
setOpening(false);
updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev);
return {
...completed,
turns: [
...completed.turns,
{ role: "tool", label: tr("Demande déjà arrêtée.", "Request already stopped."), receivedAtMs: null },
],
};
});
settleStoppedRequest(true);
return;
}
if (interactionBeingCancelled) {
cancelledInteractionIdsRef.current.delete(interactionBeingCancelled);
}
ignoreUnscopedChunksAfterStopRef.current = false;
setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({
...prev,
@ -2419,6 +2473,7 @@ export function CustomAgentChatView({
],
}));
} finally {
stopInFlightRef.current = false;
setStopping(false);
}
}

View File

@ -13,6 +13,7 @@ import type {
AgentDrift,
AgentProfile,
AppExitWorkGuardState,
CancelAgentChatResult,
CustomProviderConfig,
DomainEvent,
EffortSelection,
@ -398,7 +399,7 @@ export interface AgentGateway {
/** Clears a persisted custom chat draft. */
clearChatDraft?(scope: ChatDraftScope): Promise<void>;
/** Interrupts only the current turn of a live structured session. */
cancelAgentChat?(sessionId: string): Promise<void>;
cancelAgentChat?(sessionId: string): Promise<CancelAgentChatResult>;
/** Shuts a live structured session down. */
closeAgentChat?(sessionId: string): Promise<void>;
/**