Files
PaperClipAI/ui/src/components/ActiveAgentsPanel.test.tsx
T
DottaandPaperclip efc2e6810e fix: show each task once in dashboard agent cards (#14847)
## Thinking Path

> - Paperclip helps people manage AI agents and their tasks.
> - The dashboard shows recent agent activity in compact cards.
> - Those cards use run records, so two runs for one task can create
duplicate task cards.
> - An operator needs to see each task once when scanning the dashboard.
> - This pull request selects one run per linked task before it applies
the card limit.
> - The live runs page still shows each run for run inspection.

## Linked Issues or Issue Description

**What happened?**

The dashboard showed the same task in two agent cards when that task had
both an active run and a completed run.

**Expected behavior**

The dashboard should show a linked task at most once. It should keep the
active run card when one is present.

**Steps to reproduce**

1. Start an agent run for a task that already has a completed run.
2. Open the company dashboard.
3. Observe two cards linked to the same task.

**Paperclip version or commit**

Reproduced on the pre-change master at `8b4aa0692`.

**Deployment mode**

Local dev, built from source. The bug is in the core dashboard UI and
does not depend on an agent adapter or database mode.

## What Changed

- Select distinct linked tasks from capped active and recent run samples
before applying the dashboard card limit.
- Keep separate cards for runs without a linked task.
- Preserve the dashboard's count of additional distinct cards behind the
live-runs link.
- Add UI and embedded Postgres regression tests for duplicate runs and
document the dashboard rule.
- Give the existing multi-request cross-tenant authorization test enough
time on loaded CI runners.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/ActiveAgentsPanel.test.tsx`
- `pnpm --filter @paperclipai/ui exec vitest run
src/api/heartbeats.test.ts`
- `pnpm exec vitest run server/src/__tests__/dashboard-service.test.ts
server/src/__tests__/agent-live-run-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui build`
- `pnpm -r typecheck`
- `pnpm build`
- `pnpm check:token-gates`
- Review the dashboard with an active and a completed run on the same
task. Confirm that it shows one card. Open Live agent runs to inspect
both run records.

## Risks

- A very high volume of recent runs for one task can fill the capped
sample and leave older tasks off the dashboard. The Live runs page
remains available for full run inspection.
- The dashboard may fetch up to 50 distinct run representatives to
preserve its overflow count. The default run API response and persisted
data are unchanged.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-6. The runtime does not expose the exact model ID or
context window size to this task. The model used reasoning, tool calls,
and code execution.

## 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-01 12:18:35 -05:00

359 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActiveAgentsPanel, AgentRunCard } from "./ActiveAgentsPanel";
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("./RunChatSurface", () => ({
RunChatSurface: () => <div>Run output</div>,
}));
vi.mock("./transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({
transcriptByRun: new Map(),
hasOutputForRun: () => false,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let index = 0; index < attempts; index += 1) {
await flushReact();
try {
assertion();
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function createRun(index: number) {
return {
id: `run-${index}`,
status: "running",
invocationSource: "assignment",
triggerDetail: null,
startedAt: "2026-04-24T12:00:00.000Z",
finishedAt: null,
createdAt: `2026-04-24T12:00:0${index}.000Z`,
agentId: `agent-${index}`,
agentName: `Agent ${index}`,
adapterType: "codex_local",
issueId: null,
};
}
function createIssueRun(index: number, issueId: string) {
return {
...createRun(index),
issueId,
};
}
function createIssue(id: string, identifier: string, title: string) {
return {
id,
companyId: "company-1",
identifier,
title,
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
parentId: null,
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
goalId: null,
labels: [],
blockedByIssueIds: [],
blocksIssueIds: [],
createdAt: "2026-04-24T12:00:00.000Z",
updatedAt: "2026-04-24T12:00:00.000Z",
};
}
describe("ActiveAgentsPanel", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
mockIssuesApi.get.mockRejectedValue(new Error("Issue not found"));
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("links hidden active/recent runs to the full live dashboard", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 4,
limit: undefined,
distinctTasks: false,
});
const moreLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("more active/recent"),
);
expect(moreLink?.getAttribute("href")).toBe("/dashboard/live");
expect(container.textContent).not.toContain("Run output");
await act(async () => {
root.unmount();
});
});
it("shows each linked task once on the dashboard while keeping distinct taskless runs", async () => {
const repeatedIssueId = "issue-repeated";
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
createIssueRun(1, repeatedIssueId),
{ ...createIssueRun(2, repeatedIssueId), status: "succeeded", finishedAt: "2026-04-24T11:00:00.000Z" },
createIssueRun(3, "issue-other"),
createRun(4),
createRun(5),
createRun(6),
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" dedupeLinkedTasks />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 4,
limit: undefined,
distinctTasks: true,
});
expect([...container.querySelectorAll(".dashboard-agent-card")].map((card) =>
card.querySelector('a[aria-label$=". View run"]')?.getAttribute("href"),
)).toEqual([
"/agents/agent-1/runs/run-1",
"/agents/agent-3/runs/run-3",
"/agents/agent-4/runs/run-4",
"/agents/agent-5/runs/run-5",
]);
expect(container.textContent).toContain("1 more active/recent run");
await act(async () => root.unmount());
});
it("can request the full live dashboard page limit without a hidden-runs link", async () => {
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
createIssueRun(1, "issue-repeated"),
{ ...createIssueRun(2, "issue-repeated"), status: "succeeded" },
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel
companyId="company-1"
minRunCount={50}
fetchLimit={50}
cardLimit={50}
queryScope="dashboard-live"
showMoreLink={false}
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 50,
limit: 50,
distinctTasks: false,
});
expect(container.textContent).not.toContain("more active/recent");
expect(container.textContent).not.toContain("Run output");
expect(container.querySelectorAll(".dashboard-agent-card")).toHaveLength(2);
await act(async () => {
root.unmount();
});
});
it("loads exact visible run issues so task names render even when the issue list page would miss them", async () => {
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
createIssueRun(1, "65274215-0000-4000-8000-000000000000"),
]);
mockIssuesApi.get.mockResolvedValue(createIssue(
"65274215-0000-4000-8000-000000000000",
"PAP-3562",
"Phase 4B: Implement LLM Wiki distillation UI",
));
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" />
</QueryClientProvider>,
);
});
await flushReact();
await waitForMicrotaskAssertion(() => {
expect(mockIssuesApi.get).toHaveBeenCalledWith("65274215-0000-4000-8000-000000000000");
const issueLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("Phase 4B"),
);
expect(issueLink?.textContent).toContain("Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.textContent).toContain("PAP-3562");
expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562");
});
await act(async () => {
root.unmount();
});
});
it("keeps run outcomes distinct from the linked task status", async () => {
const root = createRoot(container);
const statuses = ["running", "queued", "succeeded", "failed", "timed_out", "cancelled", "interrupted"];
await act(async () => {
root.render(<>{statuses.map((status, index) => (
<AgentRunCard
key={status}
companyId="company-1"
run={{ ...createIssueRun(index, "issue-1"), status }}
issue={{ title: "Review release notes", identifier: "PAP-559", status: "in_review" }}
/>
))}</>);
});
const headers = [...container.querySelectorAll('a[aria-label$=". View run"]')];
expect(headers.map((header) => header.getAttribute("aria-label"))).toEqual([
"Agent 0 — Running. View run", "Agent 1 — Queued. View run",
"Agent 2 — Succeeded. View run", "Agent 3 — Failed. View run",
"Agent 4 — Timed out. View run", "Agent 5 — Cancelled. View run",
"Agent 6 — Interrupted. View run",
]);
expect(headers.every((header) => header.querySelector("svg") === null)).toBe(true);
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll('[aria-label="Task in review"]')).toHaveLength(7);
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')?.getAttribute("href"))
.toBe("/agents/agent-0/runs/run-0");
await act(async () => root.unmount());
});
it("shows an answered Slack task as idle without changing the run outcome", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard companyId="company-1"
run={{ ...createIssueRun(0, "issue-1"), status: "succeeded" }}
issue={{ title: "Slack conversation", identifier: "PAP-559", status: "in_review", externalConversationState: "waiting" }} />);
});
expect(container.querySelector('[aria-label="Agent 0 — Succeeded. View run"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Task idle"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Task in review"]')).toBeNull();
await act(async () => root.unmount());
});
it("keeps a failed task lookup navigable and shows a clear error", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard companyId="company-1" run={createIssueRun(1, "issue-missing")} issueLoadFailed />);
});
expect(container.textContent).toContain("Task unavailable");
expect(container.querySelector('a[href="/issues/issue-missing"]')).not.toBeNull();
await act(async () => root.unmount());
});
it("does not animate running records while execution is reconnecting", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard
companyId="company-1"
run={{
...createRun(0),
execution: {
phase: "reconnecting", label: "Reconnecting", cause: null,
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
recoveryOwner: "agent", nextAction: null, permittedActions: ["inspect_run"],
predecessorRunId: null, successorRunId: null,
},
}}
/>);
});
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')).not.toBeNull();
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
await act(async () => root.unmount());
});
});