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