fix(custom-cli): le bouton Stop annule le tour courant sans fermer la session et cascade l'annulation aux agents délégués — stop_agent suit l'arbre des dépendances actives (cancel_current_turn + préemption runtime + enfants d'exécution), TurnCancel tue le process du tour en cours, autorisation requester/sous-arbre sur idea_stop_agent, fixtures DTO runtime_state/node_id alignées — #257 (QA verte)

This commit is contained in:
2026-08-28 22:57:32 +02:00
parent 9505a3305d
commit 1aabc3d702
12 changed files with 534 additions and 110 deletions

View File

@ -856,28 +856,27 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByText("Le modèle n'a renvoyé aucune réponse.")).toBeNull();
});
it("stops the live agent with cascade feedback and releases the structured session", async () => {
let resolveStop: (value: {
agentId: string;
sessionId: string;
kind: "structured";
}) => void = () => {};
it("cancels only the current request and keeps the structured session usable", async () => {
let resolveCancel: () => void = () => {};
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
stopLiveAgent: vi.fn(
sendAgentChat: vi
.fn()
.mockImplementationOnce(() => new Promise<void>(() => {}))
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({ kind: "final", content: "Next answer" });
}),
stopLiveAgent: vi.fn(),
cancelAgentChat: vi.fn(
() =>
new Promise<{ agentId: string; sessionId: string; kind: "structured" }>(
(resolve) => {
resolveStop = resolve;
},
),
new Promise<void>((resolve) => {
resolveCancel = resolve;
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
@ -928,28 +927,35 @@ describe("CustomAgentChatView", () => {
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
const stoppingLabel = await screen.findByText("Arrêt en cours...");
const stoppingLabel = await screen.findByText("Stopping...");
expect(stoppingLabel.closest("button")?.hasAttribute("disabled")).toBe(true);
fireEvent.click(stoppingLabel.closest("button")!);
await waitFor(() =>
expect(agent.stopLiveAgent).toHaveBeenCalledWith("project-1", "agent-1"),
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
);
resolveStop({
agentId: "agent-1",
sessionId: "chat-session-1",
kind: "structured",
});
await waitFor(() => expect(onSessionId).toHaveBeenCalledWith(null));
expect(agent.cancelAgentChat).toHaveBeenCalledTimes(1);
resolveCancel();
await waitFor(() => expect(screen.getByText("Request stopped.")).toBeTruthy());
expect(hasProjectCliInteraction("project-1")).toBe(false);
expect(agent.cancelAgentChat).not.toHaveBeenCalled();
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
expect(agent.closeAgentChat).not.toHaveBeenCalled();
expect(
screen.getByText(
"Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés.",
),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled"),
).toBe(true);
const composer = screen.getByLabelText(/message CLI custom/);
expect(composer).toHaveProperty("disabled", false);
fireEvent.change(composer, {
target: { value: "next turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-1",
"next turn",
expect.any(Function),
);
expect(await screen.findByText("Next answer")).toBeTruthy();
});
it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => {
@ -4848,7 +4854,7 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("treats NOT_FOUND during cancel as an already-gone session", async () => {
it("treats NOT_FOUND during request cancel as an already-stopped request", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
@ -4856,11 +4862,11 @@ describe("CustomAgentChatView", () => {
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
stopLiveAgent: vi.fn().mockRejectedValueOnce({
stopLiveAgent: vi.fn(),
cancelAgentChat: vi.fn().mockRejectedValueOnce({
code: "NOT_FOUND",
message: "agent agent-1 has no live session",
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
@ -4902,9 +4908,10 @@ describe("CustomAgentChatView", () => {
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
expect(await screen.findByText("Session déjà arrêtée.")).toBeTruthy();
expect(onSessionId).toHaveBeenCalledWith(null);
expect(agent.cancelAgentChat).not.toHaveBeenCalled();
expect(await screen.findByText("Request already stopped.")).toBeTruthy();
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(agent.cancelAgentChat).toHaveBeenCalledWith("gone-session");
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
expect(agent.closeAgentChat).not.toHaveBeenCalled();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});

View File

@ -1070,7 +1070,7 @@ export function CustomAgentChatView({
});
const delegationActive = requesterDelegation.delegationTargetName !== null;
const effectiveBusy =
busy || requesterDelegation.currentAgentBusy || delegationActive;
busy || stopping || requesterDelegation.currentAgentBusy || delegationActive;
const delegationFallbackText = requesterDelegation.delegationTargetName
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
: "Délégation en cours.";
@ -1982,21 +1982,19 @@ export function CustomAgentChatView({
}
}
async function stopAgent() {
async function stopCurrentRequest() {
const sid = sessionRef.current;
if (!sid || !agent.stopLiveAgent || stopping) return;
if (!sid || !agent.cancelAgentChat || stopping) return;
setStopping(true);
setError(null);
try {
const stopped = await agent.stopLiveAgent(projectId, agentId);
await agent.cancelAgentChat(sid);
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
setOpening(false);
setCurrentSession(null);
publishSessionId(null);
updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev);
return {
@ -2005,10 +2003,7 @@ export function CustomAgentChatView({
...completed.turns,
{
role: "tool",
label:
stopped.kind === "structured"
? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés."
: "Agent arrêté.",
label: "Request stopped.",
receivedAtMs: Date.now(),
},
],
@ -2022,15 +2017,13 @@ export function CustomAgentChatView({
}
setBusy(false);
setOpening(false);
setCurrentSession(null);
publishSessionId(null);
updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev);
return {
...completed,
turns: [
...completed.turns,
{ role: "tool", label: "Session déjà arrêtée.", receivedAtMs: Date.now() },
{ role: "tool", label: "Request already stopped.", receivedAtMs: Date.now() },
],
};
});
@ -2043,7 +2036,7 @@ export function CustomAgentChatView({
...prev.turns,
{
role: "error",
text: `Arrêt de l'agent échoué: ${describe(e)}`,
text: `Arrêt de la demande échoué: ${describe(e)}`,
receivedAtMs: Date.now(),
},
],
@ -2417,13 +2410,15 @@ export function CustomAgentChatView({
className="shrink-0 whitespace-nowrap"
disabled={
effectiveBusy
? !supported || !currentSession || !agent.stopLiveAgent || stopping
? !supported || !currentSession || !agent.cancelAgentChat || stopping
: !canSend
}
loading={stopping}
onClick={() => (effectiveBusy ? void stopAgent() : void send())}
title={effectiveBusy ? "Arrêter la demande en cours" : undefined}
aria-label={effectiveBusy ? "Stop" : undefined}
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
>
{stopping ? "Arrêt en cours..." : effectiveBusy ? "Stop" : "Envoyer"}
{stopping ? "Stopping..." : effectiveBusy ? "Stop" : "Envoyer"}
</Button>
</div>
</div>