Files
PaperClipAI/ui/src/components/InboxAgentPolicyControl.test.tsx
T
59fb27ff79 feat(inbox): let agents safely tidy user inboxes (#9724)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies and their work
> - The inbox is a per-user attention view, so archiving an item must
not alter the underlying issue, assignment, or status
> - Agents can help responsible users tidy resolved work only when the
action is company-scoped, reversible, policy-controlled, and fully
attributable
> - The database and authorization foundations landed in #9654 and
#9658, but the end-to-end archive routes, audit details, agent workflow
guidance, and operator UI still need to ship together
> - Separate stacked PRs #9659 and #9661 made the complete behavior
harder to review and land as one coherent capability
> - This pull request consolidates the remaining server,
shared-contract, documentation, skill, and UI work on top of current
master
> - The benefit is a single reviewable change that lets agents safely
archive responsible-user inbox items and lets users control or undo that
behavior

## Linked Issues or Issue Description

### Subsystem affected

Cross-cutting inbox management across shared contracts, server
authorization/routes/services, shipped agent skills, and the board UI.

### Problem or motivation

Agents may complete work whose issue remains in the responsible user's
Mine inbox. Existing board-user archive behavior does not provide the
agent-facing policy endpoints, target resolution, heartbeat-run
attribution, typed denials, conservative workflow guidance, or UI needed
for safe agent-managed cleanup.

### Proposed solution

Allow authorized agents to archive or unarchive responsible-user inbox
items under the user's open, allowlist, or disabled policy; preserve
actor/agent/run attribution in issue detail and activity records; expose
policy controls and agent archive attribution in the UI; and document
conservative cleanup rules for agents and PR gardening.

### Alternatives considered

- Reuse generic issue mutation permissions: rejected because inbox state
belongs to a target user and requires user-scoped authorization.
- Automatically archive every completed or closed item: rejected because
completion signals can still require human review or a decision.
- Keep the backend and UI as separate stacked PRs: superseded by this
consolidated PR so the complete user-visible behavior can be reviewed
and verified together.

### Related work

- Builds on merged foundations #9654 and #9658.
- Supersedes the remaining stacked changes in #9659 and #9661.
- `ROADMAP.md` has no overlapping inbox archive or inbox authorization
initiative.

## What Changed

- Added shared inbox-agent policy types and validators plus
company-scoped self-service policy routes and OpenAPI coverage.
- Enabled agent archive/unarchive mutations with responsible-user
targeting, policy enforcement, typed failures, attribution, idempotency,
and detailed activity auditing.
- Returned agent archive attribution in issue detail and documented
reversible inbox cleanup semantics in the implementation spec and
Paperclip skill.
- Added conservative PR-gardening inbox tidy guidance that keeps GitHub
access read-only and avoids archiving work that still needs human
action.
- Added the Profile settings policy control and Issue Properties
attribution/unarchive UI with focused component coverage and narrow-pane
handling.

## Verification

- `pnpm exec vitest run
server/src/__tests__/inbox-archive-routes.test.ts
server/src/__tests__/inbox-agent-policy-routes.test.ts
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/openapi-routes.test.ts
ui/src/components/InboxAgentPolicyControl.test.tsx
ui/src/components/IssueProperties.test.tsx` — 110 passed.
- `pnpm --filter @paperclipai/db exec vitest run
src/inbox-archive-agent-policies-migration.test.ts` — 1 passed.
- `node --test
.agents/skills/pr-gardening/scripts/pr-gardening.test.mjs` — 9 passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm check:token-gates` — changed files are clean; the
repository-wide command currently reports nine unrelated pre-existing
`#9627` literals outside this PR's diff.

## Risks

- Agent inbox mutations broaden an existing endpoint path, so
authorization and target resolution must remain fail-closed; focused
route and authorization tests cover allowed and denied paths.
- Archive state affects only the responsible user's inbox presentation
and remains reversible; it does not mutate issue status, assignment, or
visibility.
- The UI policy defaults to the existing open behavior, while allowlist
and disabled modes can reduce agent access.
- This PR intentionally builds on #9654 and #9658 and contains no new
migration number or modification to an already-applied migration.

> 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 using GPT-5.4, medium reasoning, repository tool use,
shell execution, code review, and test 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
(`feat/inbox-agent-archive-complete`) 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>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:49:18 -05:00

201 lines
6.9 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { InboxAgentPolicy } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InboxAgentPolicyControl } from "./InboxAgentPolicyControl";
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockInboxAgentPolicyApi = vi.hoisted(() => ({ getMine: vi.fn(), updateMine: vi.fn() }));
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("@/api/inbox-agent-policy", () => ({ inboxAgentPolicyApi: mockInboxAgentPolicyApi }));
vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null }));
// 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 flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function policy(overrides: Partial<InboxAgentPolicy> = {}): InboxAgentPolicy {
return {
companyId: "company-1",
userId: "user-1",
mode: "open",
allowedAgentIds: [],
materialized: false,
createdAt: null,
updatedAt: null,
...overrides,
};
}
function render(container: HTMLDivElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<InboxAgentPolicyControl companyId="company-1" />
</QueryClientProvider>,
);
});
return root;
}
function optionByTitle(container: HTMLElement, title: string) {
return Array.from(container.querySelectorAll('[role="radio"]'))
.find((node) => node.textContent?.includes(title)) as HTMLButtonElement | undefined;
}
describe("InboxAgentPolicyControl", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([
{ id: "agent-1", name: "Gardener", role: "gardener", status: "active", icon: null },
{ id: "agent-2", name: "Coder", role: "engineer", status: "active", icon: null },
{ id: "agent-3", name: "Retired", role: "engineer", status: "terminated", icon: null },
]);
mockInboxAgentPolicyApi.getMine.mockResolvedValue(policy());
mockInboxAgentPolicyApi.updateMine.mockImplementation((_companyId: string, input) =>
Promise.resolve(policy({ ...input, materialized: true })),
);
});
afterEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("surfaces policy load failures instead of staying on loading", async () => {
mockInboxAgentPolicyApi.getMine.mockRejectedValue(new Error("Policy endpoint failed"));
const root = render(container);
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Policy endpoint failed");
expect(container.textContent).not.toContain("Loading inbox agent policy");
});
act(() => root.unmount());
});
it("renders all three policy states with the persisted mode selected", async () => {
mockInboxAgentPolicyApi.getMine.mockResolvedValue(policy({ mode: "disabled" }));
const root = render(container);
await flush();
await waitForAssertion(() => {
expect(optionByTitle(container, "Any of my agents")).toBeTruthy();
expect(optionByTitle(container, "Only chosen agents")).toBeTruthy();
expect(optionByTitle(container, "Off")).toBeTruthy();
expect(optionByTitle(container, "Off")?.getAttribute("aria-checked")).toBe("true");
expect(optionByTitle(container, "Any of my agents")?.getAttribute("aria-checked")).toBe("false");
});
act(() => root.unmount());
});
it("round-trips an allowlist selection through the PUT endpoint", async () => {
const root = render(container);
await flush();
// Save disabled until the draft diverges from the persisted policy.
await waitForAssertion(() => {
const save = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save"));
expect(save?.disabled).toBe(true);
});
// Switch to allowlist — only non-terminated agents are selectable.
await act(async () => optionByTitle(container, "Only chosen agents")!.click());
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Gardener");
expect(container.textContent).toContain("Coder");
expect(container.textContent).not.toContain("Retired");
});
const gardenerCheckbox = container.querySelector<HTMLButtonElement>(
'button[aria-label="Allow Gardener to tidy my inbox"]',
);
expect(gardenerCheckbox).toBeTruthy();
await act(async () => gardenerCheckbox!.click());
await flush();
const saveButton = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save"))!;
await waitForAssertion(() => expect(saveButton.disabled).toBe(false));
await act(async () => saveButton.click());
await flush();
expect(mockInboxAgentPolicyApi.updateMine).toHaveBeenCalledWith("company-1", {
mode: "allowlist",
allowedAgentIds: ["agent-1"],
});
await waitForAssertion(() => {
expect(container.textContent).toContain("Saved");
const save = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save"));
expect(save?.disabled).toBe(true);
});
act(() => root.unmount());
});
it("clears the allowlist when switching to Off before saving", async () => {
mockInboxAgentPolicyApi.getMine.mockResolvedValue(policy({ mode: "allowlist", allowedAgentIds: ["agent-1"], materialized: true }));
const root = render(container);
await flush();
await waitForAssertion(() => {
expect(optionByTitle(container, "Only chosen agents")?.getAttribute("aria-checked")).toBe("true");
});
await act(async () => optionByTitle(container, "Off")!.click());
await flush();
const saveButton = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save"))!;
await waitForAssertion(() => expect(saveButton.disabled).toBe(false));
await act(async () => saveButton.click());
await flush();
expect(mockInboxAgentPolicyApi.updateMine).toHaveBeenCalledWith("company-1", {
mode: "disabled",
allowedAgentIds: [],
});
act(() => root.unmount());
});
});