fix(chat): le bouton Stop de la CLI custom arrête l'agent en cascade (#187)
Le Stop du CustomAgentChatView n'annulait que le tour courant sans libérer la session ni toucher aux tâches enfants ou appels MCP en cours. Il appelle désormais stop_live_agent sur l'agent vivant, qui: - ajoute le port StopAgentExecutionCascade (best-effort, idempotent), implémenté par OrchestratorService pour annuler les idea_run_in_background rattachés au rendez-vous actif (via BackgroundTaskRunner.cancel); - cascade l'arrêt aux wait-dépendances (stop_turn_watch + preempt) pour interrompre les appels MCP et les attentes inter-agents; - libère et supprime la session structurée. UI: nouvel état "Arrêt en cours..." (bouton disabled + loading) pendant l'arrêt, libération du sessionId, label expliquant la cascade demandée. La Tauri command cancel_agent_chat est alignée sur stop_live_agent + stop_turn_watch agent et dépendances. Tests: SpyBackgroundRunner (cascade n'annule que les tâches rattachées), workstate_actions cascade best-effort, CustomAgentChatView stop+cascade feedback et fallback NOT_FOUND.
This commit is contained in:
@ -101,7 +101,12 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancels only the current turn and keeps the structured session alive", async () => {
|
||||
it("stops the live agent with cascade feedback and releases the structured session", async () => {
|
||||
let resolveStop: (value: {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
kind: "structured";
|
||||
}) => void = () => {};
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
@ -109,6 +114,14 @@ describe("CustomAgentChatView", () => {
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
||||
stopLiveAgent: vi.fn(
|
||||
() =>
|
||||
new Promise<{ agentId: string; sessionId: string; kind: "structured" }>(
|
||||
(resolve) => {
|
||||
resolveStop = resolve;
|
||||
},
|
||||
),
|
||||
),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
@ -159,24 +172,27 @@ describe("CustomAgentChatView", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
|
||||
const stoppingLabel = await screen.findByText("Arrêt en cours...");
|
||||
expect(stoppingLabel.closest("button")?.hasAttribute("disabled")).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
|
||||
expect(agent.stopLiveAgent).toHaveBeenCalledWith("project-1", "agent-1"),
|
||||
);
|
||||
expect(agent.closeAgentChat).not.toHaveBeenCalled();
|
||||
expect(onSessionId).not.toHaveBeenCalledWith(null);
|
||||
expect(screen.getByText("Tour interrompu.")).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
target: { value: "second turn" },
|
||||
resolveStop({
|
||||
agentId: "agent-1",
|
||||
sessionId: "chat-session-1",
|
||||
kind: "structured",
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
|
||||
expect(agent.sendAgentChat).toHaveBeenLastCalledWith(
|
||||
"chat-session-1",
|
||||
"second turn",
|
||||
expect.any(Function),
|
||||
);
|
||||
await waitFor(() => expect(onSessionId).toHaveBeenCalledWith(null));
|
||||
expect(agent.cancelAgentChat).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);
|
||||
});
|
||||
|
||||
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
|
||||
@ -2569,10 +2585,11 @@ describe("CustomAgentChatView", () => {
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
||||
cancelAgentChat: vi.fn().mockRejectedValueOnce({
|
||||
stopLiveAgent: vi.fn().mockRejectedValueOnce({
|
||||
code: "NOT_FOUND",
|
||||
message: "not found: structured session gone-session",
|
||||
message: "agent agent-1 has no live session",
|
||||
}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
const onSessionId = vi.fn();
|
||||
@ -2614,8 +2631,9 @@ describe("CustomAgentChatView", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
|
||||
expect(await screen.findByText("Session déjà fermée.")).toBeTruthy();
|
||||
expect(await screen.findByText("Session déjà arrêtée.")).toBeTruthy();
|
||||
expect(onSessionId).toHaveBeenCalledWith(null);
|
||||
expect(agent.cancelAgentChat).not.toHaveBeenCalled();
|
||||
expect(agent.closeAgentChat).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/not found: structured session/)).toBeNull();
|
||||
});
|
||||
|
||||
@ -534,6 +534,7 @@ export function CustomAgentChatView({
|
||||
useState<ProfileCommandDialogState | null>(null);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@ -1209,23 +1210,46 @@ export function CustomAgentChatView({
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
async function stopAgent() {
|
||||
const sid = sessionRef.current;
|
||||
if (!sid || !agent.cancelAgentChat) return;
|
||||
setBusy(false);
|
||||
setOpening(false);
|
||||
if (!sid || !agent.stopLiveAgent || stopping) return;
|
||||
setStopping(true);
|
||||
setError(null);
|
||||
try {
|
||||
await agent.cancelAgentChat(sid);
|
||||
setTurns((prev) => [...prev, { role: "tool", label: "Tour interrompu." }]);
|
||||
const stopped = await agent.stopLiveAgent(projectId, agentId);
|
||||
setBusy(false);
|
||||
setOpening(false);
|
||||
setCurrentSession(null);
|
||||
publishSessionId(null);
|
||||
setTurns((prev) => [
|
||||
...completeRunningProgress(prev),
|
||||
{
|
||||
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é.",
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
if (isNotFound(e)) {
|
||||
setBusy(false);
|
||||
setOpening(false);
|
||||
setCurrentSession(null);
|
||||
publishSessionId(null);
|
||||
setTurns((prev) => [...prev, { role: "tool", label: "Session déjà fermée." }]);
|
||||
setTurns((prev) => [
|
||||
...completeRunningProgress(prev),
|
||||
{ role: "tool", label: "Session déjà arrêtée." },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
setError(describe(e));
|
||||
setTurns((prev) => [
|
||||
...prev,
|
||||
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
|
||||
]);
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1511,10 +1535,15 @@ export function CustomAgentChatView({
|
||||
size="sm"
|
||||
variant={busy ? "danger" : undefined}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
disabled={busy ? !supported || !currentSession : !canSend}
|
||||
onClick={() => (busy ? void cancel() : void send())}
|
||||
disabled={
|
||||
busy
|
||||
? !supported || !currentSession || !agent.stopLiveAgent || stopping
|
||||
: !canSend
|
||||
}
|
||||
loading={stopping}
|
||||
onClick={() => (busy ? void stopAgent() : void send())}
|
||||
>
|
||||
{busy ? "Stop" : "Envoyer"}
|
||||
{stopping ? "Arrêt en cours..." : busy ? "Stop" : "Envoyer"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user