Files
PaperClipAI/ui/src/components/AgentActionButtons.test.tsx
T
DottaandPaperclip 25f2d05282 fix(ui): remove the provider trace run button (#17211)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The agent header provides actions to assign work and start runs.
> - A second run button starts a run with raw provider tracing.
> - Agent configuration already has a switch for raw tracing on future
runs.
> - This pull request removes the extra run button and its unused code.
> - The header keeps one normal run action and the saved tracing status.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The action buttons in the agent detail header.

**Current behavior**

Administrators see Run now and Run with provider trace next to each
other.

**Proposed behavior**

Remove Run with provider trace from the header. Use Capture raw provider
traces in the configuration Debugging section to enable tracing for
future runs.

**Reason and benefit**

Keep the normal run action clear. Put the tracing setting with the other
agent settings.

**Breaking changes**

The separate trace button is removed. The tracing configuration, trace
API, and run inspector actions stay available.

## What Changed

- Remove the trace button, its property, and its mutation.
- Remove the property from the agent detail page.
- Update tests for normal runs with saved tracing on and off.
- Check normal actions for Codex, Dot, and Muse.
- Document the tracing setting in the feature map.

## Verification

- Passed: 178 focused UI tests across AgentActionButtons,
AgentDetail.accounting, RunnerInspector, AgentConfigForm, and
AgentConfigForm.render.
- Passed: pnpm check:token-gates.
- Passed: node scripts/check-maps.mjs.
- Passed: git diff --check.
- Passed: [PR CI
workflow](https://github.com/paperclipai/paperclip/actions/runs/39796866659),
including the required verify and e2e gates.
- Passed: Greptile 5/5 with no open review threads.
- Two optional job badges still say running. Their test, cleanup, and
final job steps passed. The parent workflow completed with success.
- The broad local checks could not complete with the installed
dependencies. The full typecheck and build require smol-toml. The UI
checks also require @spekoai/client and the plugin SDK UI build. The
full test run was stopped after many suites could not load. The PR
workflow and both required gates passed for the current head.
- To inspect the UI, open an agent. Confirm there is one Run now action.
Enable Capture raw provider traces under configuration, Debugging. Save
the change. Confirm the header shows Raw tracing on and still has one
run action.

## Risks

- Low risk. This removes one UI action and its unused code.
- Administrators use the saved Debugging setting for tracing future
runs.
- No API, database, or permission changes.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, tool use, and code
execution. The exact serving model ID and context window are not exposed
in this run.

## 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-10-22 10:02:51 -05:00

335 lines
13 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.each(["codex_local", "claude_local", "paperclip_runner"])("returns unfinished %s agents to their saved runner", async adapterType => {
render(makeAgent({ adapterType, status: "paused", lifecycleState: "paused", pauseReason: "setup" }));
await flushReact();
const finish = [...container.querySelectorAll("button")].find(button => button.textContent === "Finish setup");
expect(finish).toBeTruthy();
expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resume")).toBe(false);
await act(() => finish!.click());
expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining("setupAgentId=agent-1"));
expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining(`runner=${adapterType === "paperclip_runner" ? "paperclip" : "legacy"}`));
expect(mockAgentsApi.resume).not.toHaveBeenCalled();
});
it.each(["preparing", "verifying", "resuming"] as const)("offers pause rather than resume during %s", async lifecycleState => {
render(makeAgent({ status: "paused", lifecycleState }));
await flushReact();
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.some(button => button.textContent === "Resume")).toBe(false);
const pause = buttons.find(button => button.textContent === "Pause")!;
expect(pause.disabled).toBe(false);
await act(async () => pause.click());
await flushReact();
expect(mockAgentsApi.pause).toHaveBeenCalledWith("agent-1", "company-1");
});
it.each(["pausing", "terminating", "cleaning_up", "terminated", "rejected"] as const)("disables pause and resume during %s", async lifecycleState => {
render(makeAgent({ status: lifecycleState === "pausing" ? "paused" : "terminated", lifecycleState }));
await flushReact();
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.find(button => button.textContent === "Pause")!.disabled).toBe(true);
if (lifecycleState !== "pausing") {
expect(buttons.find(button => button.textContent === "Run Heartbeat")!.disabled).toBe(true);
expect(buttons.find(button => button.textContent === "Assign Task")!.disabled).toBe(true);
}
});
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.each([false, true])("keeps the normal run action when persistent tracing is %s", async tracingEnabled => {
render(makeAgent({ runtimeConfig: tracingEnabled ? { debug: { providerTrace: "raw" } } : {} }));
await flushReact();
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.some(button => button.textContent?.includes("Run with provider trace"))).toBe(false);
expect(container.textContent?.includes("Raw tracing on")).toBe(tracingEnabled);
await act(async () => {
buttons.find(button => button.textContent === "Run Heartbeat")!.click();
});
await flushReact();
expect(mockAgentsApi.invoke).toHaveBeenCalledExactlyOnceWith("agent-1", "company-1");
expect(mockNavigate).toHaveBeenCalledWith("/agents/alpha/runs/run-1");
});
it.each(["terminating", "cleaning_up"] as const)("keeps the detail page open during %s", async lifecycleState => {
mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated", lifecycleState }));
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();
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"] });
});
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"] });
});
it("does not terminate when navigation away from a dirty detail page is rejected", async () => {
const onBeforeNavigate = vi.fn().mockReturnValue(false);
render(makeAgent(), { onBeforeNavigate, onTerminateSuccess: vi.fn() });
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"));
await act(async () => {
terminateButton?.click();
});
await flushReact();
expect(onBeforeNavigate).toHaveBeenCalledOnce();
expect(mockAgentsApi.terminate).not.toHaveBeenCalled();
});
it("rechecks navigation when the form becomes dirty while termination is pending", async () => {
let resolveTermination!: (agent: Agent) => void;
mockAgentsApi.terminate.mockReturnValue(new Promise((resolve) => {
resolveTermination = resolve;
}));
const onBeforeNavigate = vi.fn().mockReturnValueOnce(true).mockReturnValue(false);
const onTerminateSuccess = vi.fn();
const agent = makeAgent();
render(agent, {
hasPendingNavigationChanges: false,
onBeforeNavigate,
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"));
await act(async () => {
terminateButton?.click();
});
await flushReact();
render(agent, {
hasPendingNavigationChanges: true,
onBeforeNavigate,
onTerminateSuccess,
});
await flushReact();
resolveTermination(makeAgent({ status: "terminated" }));
await flushReact();
expect(onBeforeNavigate).toHaveBeenCalledTimes(2);
expect(onTerminateSuccess).not.toHaveBeenCalled();
});
});