mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## 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>
209 lines
7.0 KiB
TypeScript
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"] });
|
|
});
|
|
});
|