From bbf5cfeb62e7c28f29d92604a2cddd138bd50486 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 10 Nov 2026 15:38:00 -0800 Subject: [PATCH] fix(agents): return to the index after termination is accepted (#20667) Return to the Agents index when termination is accepted, including while asynchronous cleanup continues, so the profile does not remain on an Agent not found error. Cover both action menus, roster refresh, failed requests, and unsaved changes during pending termination. Verified with 53 focused tests, the full UI suite (11,949 passed; one skipped), UI typecheck and build, all applicable CI checks, and Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip --- ui/src/components/AgentActionButtons.test.tsx | 37 ++++++++++++++++--- ui/src/components/AgentActionButtons.tsx | 3 -- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx index 1455bccb203..e5d9b89f352 100644 --- a/ui/src/components/AgentActionButtons.test.tsx +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -373,18 +373,45 @@ describe("AgentActionButtons", () => { expect(mockNavigate).toHaveBeenCalledWith("/agents/alpha/runs/run-1"); }); - it.each(["terminating", "cleaning_up"] as const)("keeps the detail page open during %s", async lifecycleState => { + it.each([ + ["terminating", false], + ["cleaning_up", false], + ["terminating", true], + ["cleaning_up", true], + ] as const)("leaves the detail page after termination is accepted during %s (work actions in menu: %s)", async (lifecycleState, workActionsInMenu) => { mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated", lifecycleState })); const onTerminateSuccess = vi.fn(); + render(makeAgent(), { onTerminateSuccess, workActionsInMenu }); + await flushReact(); + await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')!.click(); }); + await flushReact(); + await act(async () => { Array.from(document.body.querySelectorAll("button")).find(button => button.textContent?.includes("Terminate"))!.click(); }); + await flushReact(); + expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1"); + expect(onTerminateSuccess).toHaveBeenCalledWith(expect.objectContaining({ + id: "agent-1", + lifecycleState, + })); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] }); + }); + + it("keeps the detail page open and reports a failed termination", async () => { + mockAgentsApi.terminate.mockRejectedValue(new Error("Termination failed")); + const onTerminateSuccess = vi.fn(); render(makeAgent(), { onTerminateSuccess }); await flushReact(); await act(async () => { container.querySelector('[aria-label="Open actions for Alpha Agent"]')!.click(); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll("button")).find(button => button.textContent?.includes("Terminate"))!.click(); }); await flushReact(); - expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1"); + expect(onTerminateSuccess).not.toHaveBeenCalled(); - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); + expect(invalidateQueries).not.toHaveBeenCalled(); + expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ + body: "Termination failed", + tone: "error", + })); }); it("calls the terminate success handler after terminating an agent", async () => { @@ -437,7 +464,7 @@ describe("AgentActionButtons", () => { expect(mockAgentsApi.terminate).not.toHaveBeenCalled(); }); - it("rechecks navigation when the form becomes dirty while termination is pending", async () => { + it.each([undefined, "terminating", "cleaning_up"] as const)("rechecks unsaved changes before leaving after termination returns %s", async lifecycleState => { let resolveTermination!: (agent: Agent) => void; mockAgentsApi.terminate.mockReturnValue(new Promise((resolve) => { resolveTermination = resolve; @@ -469,7 +496,7 @@ describe("AgentActionButtons", () => { onTerminateSuccess, }); await flushReact(); - resolveTermination(makeAgent({ status: "terminated" })); + resolveTermination(makeAgent({ status: "terminated", lifecycleState })); await flushReact(); expect(onBeforeNavigate).toHaveBeenCalledTimes(2); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index a610530c3c5..5211b82e903 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -216,7 +216,6 @@ export function AgentActionButtons({ * omitted, failures surface as toasts (used by the list view). */ onActionError?: (message: string | null) => void; - /** Called after termination succeeds so callers can leave now-hidden detail routes. */ onTerminateSuccess?: (agent: Agent) => void; /** Extra content rendered just before the overflow menu (e.g. live-run link). */ children?: React.ReactNode; @@ -293,8 +292,6 @@ export function AgentActionButtons({ onActionError?.(null); invalidateAgent(); if (action === "terminate") { - const state = (data as Agent).lifecycleState; - if (state === "terminating" || state === "cleaning_up") return; if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return; onTerminateSuccess?.(data as Agent); }