Files
PaperClipAI/ui/src/components/AgentActionButtons.test.tsx
T
Devin FoleyandPaperclip 4c8d92f086 fix(ui): leave agent detail after termination (#10451)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The agent detail page lets operators inspect one agent and run
lifecycle actions from that context
> - Terminated agents are removed from the normal active-agent surface,
so the current detail route may no longer be fetchable after termination
> - Before this change, terminating an agent from its detail page
invalidated agent queries while leaving the browser on the now-stale
detail route
> - That refetch could surface an "Agent not found" error even though
the terminate action itself succeeded
> - The shared action button already owns the terminate mutation, so it
can notify detail-page callers when termination succeeds
> - This pull request redirects the detail page back to the agents list
after a successful terminate action
> - The benefit is that operators land on a valid route and the Back
button does not return them to the stale terminated-agent detail route

## Linked Issues or Issue Description

No direct public GitHub issue or PR was found for this detail-page
termination flow.

### What happened?

After terminating an agent from its detail page, the UI could remain on
that agent's detail route and show an "Agent not found" error after
query invalidation/refetch.

### Expected behavior

Once termination succeeds, the operator should leave the now-stale
detail page and land on a valid agents view.

### Steps to reproduce

1. Open a non-built-in agent detail page.
2. Use the overflow actions menu to terminate the agent.
3. Observe the post-termination route/error state.

### Paperclip version or commit

Current `master` before this PR.

### Deployment mode

Browser UI behavior, independent of a specific deployment mode.

Duplicate search: searched public GitHub issues and PRs for `agent not
found terminate`, `terminate agent detail`, and `Agent not found`; no
direct duplicate or viable in-flight PR was found.

## What Changed

- Added an optional `onTerminateSuccess` callback to
`AgentActionButtons`, fired only after the shared terminate mutation
succeeds.
- Wired `AgentDetail` to replace-navigate to `/agents/all` after
successful termination.
- Extended `AgentActionButtons` coverage for the terminate success path,
including API args, callback payload, and query invalidations.

## Verification

- `corepack pnpm exec vitest run
ui/src/components/AgentActionButtons.test.tsx`
- `corepack pnpm check:token-gates`
- `git diff --check origin/master..HEAD`
- Local diff scan for obvious tokens, credential filenames, and email
addresses found no matches.

## Risks

Low risk. The new callback is optional, only fires for successful
terminate actions, and preserves existing behavior for other
`AgentActionButtons` callers.

## Model Used

OpenAI Codex, GPT-5-based coding agent (`gpt-5`), tool use enabled for
repository inspection, editing, local verification, and GitHub CLI
operations. Context window details were not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-29 12:26:07 -07:00

209 lines
7.0 KiB
TypeScript

// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentActionButtons } from "./AgentActionButtons";
const mockNavigate = vi.hoisted(() => vi.fn());
const mockOpenNewIssue = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
const mockAgentsApi = vi.hoisted(() => ({
invoke: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
clearError: vi.fn(),
approve: vi.fn(),
terminate: vi.fn(),
resetSession: vi.fn(),
instructionsBundle: vi.fn(),
instructionsFile: vi.fn(),
create: vi.fn(),
hire: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
useNavigate: () => mockNavigate,
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => ({ openNewIssue: mockOpenNewIssue }),
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: mockPushToast }),
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "Alpha Agent",
urlKey: "alpha",
role: "engineer",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
describe("AgentActionButtons", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
let invalidateQueries: ReturnType<typeof vi.fn>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") as unknown as ReturnType<typeof vi.fn>;
mockAgentsApi.clearError.mockResolvedValue(makeAgent({ status: "idle" }));
mockAgentsApi.pause.mockResolvedValue(makeAgent({ status: "paused" }));
mockAgentsApi.resume.mockResolvedValue(makeAgent({ status: "idle" }));
mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated" }));
mockAgentsApi.invoke.mockResolvedValue({ id: "run-1" });
mockAgentsApi.resetSession.mockResolvedValue(undefined);
});
afterEach(async () => {
const currentRoot = root;
if (currentRoot) {
await act(async () => {
currentRoot.unmount();
});
}
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
function render(agent: Agent, props: Partial<ComponentProps<typeof AgentActionButtons>> = {}) {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<AgentActionButtons agent={agent} companyId="company-1" runLabel="Run Heartbeat" {...props} />
</QueryClientProvider>,
);
}
it("replaces the pause slot with Clear error for error agents", async () => {
render(makeAgent({ status: "error" }));
await flushReact();
expect(container.textContent).toContain("Clear error");
expect(container.textContent).not.toContain("Pause");
expect(container.textContent).toContain("Run Heartbeat");
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
});
await flushReact();
expect(document.body.textContent).toContain("Reset Sessions");
});
it("calls clearError and refreshes agent-related queries", async () => {
render(makeAgent({ status: "error" }));
await flushReact();
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Clear error and return agent to idle"]')?.click();
});
await flushReact();
expect(mockAgentsApi.clearError).toHaveBeenCalledWith("agent-1", "company-1");
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "runtime-state", "agent-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "task-sessions", "agent-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["live-runs", "company-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["heartbeats", "company-1", "agent-1"] });
});
it("keeps the normal pause action for non-error agents", async () => {
render(makeAgent({ status: "active" }));
await flushReact();
expect(container.textContent).toContain("Pause");
expect(container.textContent).not.toContain("Clear error");
});
it("calls the terminate success handler after terminating an agent", async () => {
const onTerminateSuccess = vi.fn();
render(makeAgent(), { onTerminateSuccess });
await flushReact();
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
});
await flushReact();
const terminateButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Terminate"));
expect(terminateButton).toBeTruthy();
await act(async () => {
terminateButton?.click();
});
await flushReact();
expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1");
expect(onTerminateSuccess).toHaveBeenCalledWith(expect.objectContaining({
id: "agent-1",
status: "terminated",
}));
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] });
});
});