mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The decisions desk shows pending decisions that need an operator response > - A strict decision cannot apply its effects after its target task changes > - A decision still remained pending when every target task finished after proposal > - The card also linked only the origin task, even when the decision acted on another task > - This pull request expires those moot decisions and links their target tasks > - The benefit is an accurate queue and a clear path to the work that each decision affects ## Linked Issues or Issue Description Related PR: #10801 removes the issue-page decision strip, which makes clear queue provenance more important. **What happened?** A strict decision stayed pending until its time-to-live limit after every target task reached `done`. The decision card linked only the origin task. The origin task is where the agent proposed the decision, and it can differ from the task that the decision affects. An operator could therefore open a finished task with no visible decision and no explanation of the real target. **Expected behavior** Paperclip must expire a strict decision when all of its targets finish after the decision is proposed. The card must show and link every target task that differs from the origin task. **Steps to reproduce** 1. Create a strict decision that targets an active task from a different origin task. 2. Move the target task to `done` without resolving the decision. 3. Run the decision expiry sweep. 4. Observe that the old code keeps the decision open until its time-to-live limit. 5. Observe that the old card links only the origin task. **Paperclip version or commit** The bug reproduces on upstream `master` before this pull request. **Deployment mode** Local dev and self-hosted server modes are affected because the behavior is in the shared decision service and board UI. ## What Changed - Expire an open strict decision with reason `target_completed` when every strict target reached `done` after proposal. - Keep decisions that intentionally target an already-finished task. - Keep lenient-only decisions open. - Keep continuation delivery consistent with other expiry reasons. - Add target-task links to the decision card provenance line. - Use one shared target-ID helper across signing, execution, expiry, card provenance, and resolver preloading. - Add service and UI regression tests for primary, secondary, and target-completed cases. ## Verification - `pnpm exec vitest run ui/src/components/DecisionCard.test.tsx server/src/__tests__/decisions-service.test.ts` — 51 tests passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — all gates clean. - `git diff --check origin/master...HEAD` — passed. ## Risks - Low migration risk. This change does not alter the database schema. - The expiry sweep performs the existing strict-target query and adds a snapshot comparison before expiry. - A decision remains open if any strict target is active or if a target was already `done` at proposal time. > The roadmap lists work queues as planned. This pull request fixes the existing decisions desk. It does not add a new queue subsystem. ## Model Used - Implementation: Anthropic Claude through Claude Code. The runtime did not expose the exact model snapshot or context-window size. The model used reasoning, repository tools, code execution, and test execution. - PR preparation: OpenAI Codex with GPT-5. The runtime did not expose a dated model snapshot or context-window size. The model used reasoning, repository tools, code execution, 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 (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>
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act as reactAct, type ComponentProps, type ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { DecisionCard, type DecisionIssueRef } from "./DecisionCard";
|
|
import type { Decision, DecisionEffectExecution } from "../api/decisions";
|
|
import { ThemeProvider } from "../context/ThemeContext";
|
|
import { TooltipProvider } from "./ui/tooltip";
|
|
|
|
let container: HTMLDivElement | null = null;
|
|
let root: Root | null = null;
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
if (typeof reactAct === "function") {
|
|
await reactAct(callback);
|
|
return;
|
|
}
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
|
|
<a href={to} className={className}>{children}</a>
|
|
),
|
|
useCaseHref: () => () => "#",
|
|
}));
|
|
|
|
const ISSUES: Record<string, DecisionIssueRef> = {
|
|
"issue-origin": { id: "issue-origin", identifier: "PAP-123", title: "Gardener sweep", href: "/PAP/issues/PAP-123", status: "in_progress" },
|
|
"issue-target": { id: "issue-target", identifier: "PAP-456", title: "Stale epic", href: "/PAP/issues/PAP-456", status: "backlog" },
|
|
"issue-parent": { id: "issue-parent", identifier: "PAP-789", title: "Parent task", href: "/PAP/issues/PAP-789", status: "todo" },
|
|
"issue-blocker": { id: "issue-blocker", identifier: "PAP-790", title: "Blocking task", href: "/PAP/issues/PAP-790", status: "todo" },
|
|
"issue-new": { id: "issue-new", identifier: "PAP-999", title: "Follow-up", href: "/PAP/issues/PAP-999", status: "todo" },
|
|
};
|
|
const resolveIssue = (id: string): DecisionIssueRef | null => ISSUES[id] ?? null;
|
|
|
|
function mkDecision(overrides: Partial<Decision> = {}): Decision {
|
|
return {
|
|
id: "decision-1",
|
|
companyId: "c1",
|
|
bundleId: null,
|
|
originAgentId: "agent-gardener",
|
|
originIssueId: "issue-origin",
|
|
originRunId: "run-1",
|
|
ruleKey: "stale-epic",
|
|
title: "Stale epic PAP-456",
|
|
body: "No activity for three weeks.",
|
|
options: [
|
|
{ id: "comment", label: "Comment and snooze", effects: [{ type: "comment_on_issue", targetIssueId: "issue-target", staleness: "lenient", bodyMarkdown: "nudge" }] },
|
|
],
|
|
inputs: null,
|
|
status: "open",
|
|
executionStatus: null,
|
|
chosenOptionId: null,
|
|
inputValues: null,
|
|
decidedByUserId: null,
|
|
decidedAt: null,
|
|
expiresAt: "2026-07-29T12:00:00Z",
|
|
idempotencyKey: null,
|
|
targetSnapshots: { "issue-target": { status: "backlog", assigneeAgentId: null, assigneeUserId: null, updatedAt: "2026-07-01T09:00:00Z", childCount: 2 } },
|
|
continuationPolicy: "none",
|
|
metadata: {},
|
|
createdAt: "2026-07-22T09:00:00Z",
|
|
updatedAt: "2026-07-22T09:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function exec(overrides: Partial<DecisionEffectExecution>): DecisionEffectExecution {
|
|
return {
|
|
id: `exec-${overrides.effectIndex ?? 0}`,
|
|
decisionId: "decision-1",
|
|
effectIndex: 0,
|
|
effectType: "comment_on_issue",
|
|
targetIssueId: "issue-target",
|
|
status: "executed",
|
|
result: {},
|
|
error: null,
|
|
activityLogId: null,
|
|
executedAt: "2026-07-22T10:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function render(props: Partial<ComponentProps<typeof DecisionCard>>) {
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root?.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider>
|
|
<TooltipProvider>
|
|
<DecisionCard decision={mkDecision()} resolveIssue={resolveIssue} originAgentName="Gardener" originIssue={ISSUES["issue-origin"]} {...props} />
|
|
</TooltipProvider>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
return container!;
|
|
}
|
|
|
|
function clickButtonWithText(el: HTMLElement, text: string) {
|
|
const button = [...el.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
|
|
if (!button) throw new Error(`No button with text "${text}"`);
|
|
act(() => {
|
|
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
return button;
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (root) act(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
describe("DecisionCard", () => {
|
|
it("renders a pending decision with provenance, effect summary and dismiss", () => {
|
|
const el = render({});
|
|
expect(el.textContent).toContain("Pending");
|
|
expect(el.textContent).toContain("Gardener");
|
|
expect(el.textContent).toContain("PAP-123");
|
|
expect(el.textContent).toContain("Comment on PAP-456");
|
|
expect([...el.querySelectorAll("button")].some((b) => b.textContent?.includes("Dismiss"))).toBe(true);
|
|
});
|
|
|
|
it("links the target issue the decision applies to, not just the origin", () => {
|
|
const el = render({});
|
|
expect(el.textContent).toContain("applies to");
|
|
const provenance = el.querySelector("p");
|
|
expect(provenance?.textContent).toContain("PAP-456");
|
|
expect(
|
|
[...(provenance?.querySelectorAll("a") ?? [])].some((a) => a.getAttribute("href") === "/PAP/issues/PAP-456"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("omits the applies-to link when the decision only targets its origin issue", () => {
|
|
const el = render({
|
|
decision: mkDecision({
|
|
options: [
|
|
{ id: "comment", label: "Comment", effects: [{ type: "comment_on_issue", targetIssueId: "issue-origin", staleness: "lenient", bodyMarkdown: "nudge" }] },
|
|
],
|
|
}),
|
|
});
|
|
expect(el.textContent).not.toContain("applies to");
|
|
});
|
|
|
|
it("links secondary target issues referenced by an effect", () => {
|
|
const el = render({
|
|
decision: mkDecision({
|
|
options: [{
|
|
id: "create",
|
|
label: "Create follow-up",
|
|
effects: [{
|
|
type: "create_issue",
|
|
targetIssueId: "issue-target",
|
|
staleness: "strict",
|
|
draft: {
|
|
title: "Follow-up",
|
|
parentId: "issue-parent",
|
|
blockedByIssueIds: ["issue-blocker"],
|
|
},
|
|
}],
|
|
}],
|
|
}),
|
|
});
|
|
const targetHrefs = [...(el.querySelector("p")?.querySelectorAll("a") ?? [])].map((a) => a.getAttribute("href"));
|
|
expect(targetHrefs).toEqual(expect.arrayContaining([
|
|
"/PAP/issues/PAP-456",
|
|
"/PAP/issues/PAP-789",
|
|
"/PAP/issues/PAP-790",
|
|
]));
|
|
});
|
|
|
|
it("fires onDecide with the chosen option id", () => {
|
|
const onDecide = vi.fn();
|
|
const el = render({ onDecide });
|
|
clickButtonWithText(el, "Comment and snooze");
|
|
expect(onDecide).toHaveBeenCalledWith("comment", expect.any(Object));
|
|
});
|
|
|
|
it("fires onDismiss from the always-present zero-effect dismiss", () => {
|
|
const onDismiss = vi.fn();
|
|
const el = render({ onDismiss });
|
|
clickButtonWithText(el, "Dismiss");
|
|
expect(onDismiss).toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns and disables strict options when a target is stale", () => {
|
|
const el = render({
|
|
targetChanged: { "issue-target": true },
|
|
decision: mkDecision({
|
|
options: [
|
|
{ id: "strict", label: "Cancel it", effects: [{ type: "update_issue_status", targetIssueId: "issue-target", staleness: "strict", status: "cancelled" }] },
|
|
{ id: "lenient", label: "Just comment", effects: [{ type: "comment_on_issue", targetIssueId: "issue-target", staleness: "lenient", bodyMarkdown: "hi" }] },
|
|
],
|
|
}),
|
|
});
|
|
expect(el.textContent).toContain("changed since this was proposed");
|
|
expect(el.textContent).toContain("Blocked · stale");
|
|
const strict = [...el.querySelectorAll("button")].find((b) => b.textContent?.includes("Cancel it"));
|
|
expect(strict?.disabled).toBe(true);
|
|
});
|
|
|
|
it("disables strict options when a secondary target is stale", () => {
|
|
const el = render({
|
|
targetChanged: { "issue-target": false, "issue-parent": true },
|
|
decision: mkDecision({
|
|
targetSnapshots: {
|
|
"issue-target": { status: "backlog", assigneeAgentId: null, assigneeUserId: null, updatedAt: "2026-07-01T09:00:00Z", childCount: 2 },
|
|
"issue-parent": { status: "todo", assigneeAgentId: null, assigneeUserId: null, updatedAt: "2026-07-01T09:00:00Z", childCount: 0 },
|
|
},
|
|
options: [
|
|
{
|
|
id: "strict-create",
|
|
label: "Create the follow-up",
|
|
effects: [{
|
|
type: "create_issue",
|
|
targetIssueId: "issue-target",
|
|
staleness: "strict",
|
|
draft: { title: "Follow-up", parentId: "issue-parent" },
|
|
}],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
const strict = [...el.querySelectorAll("button")].find((button) => button.textContent?.includes("Create the follow-up"));
|
|
expect(strict?.disabled).toBe(true);
|
|
expect(strict?.textContent).toContain("Blocked · stale");
|
|
});
|
|
|
|
it("gates a cancel_issue_tree option behind a type-to-confirm step", () => {
|
|
const onDecide = vi.fn();
|
|
const el = render({
|
|
onDecide,
|
|
cancelTreePreview: () => [ISSUES["issue-target"]!],
|
|
decision: mkDecision({
|
|
options: [
|
|
{ id: "cancel", label: "Cancel the tree", style: "destructive", effects: [{ type: "cancel_issue_tree", targetIssueId: "issue-target", staleness: "strict", reasonComment: "stale" }] },
|
|
],
|
|
}),
|
|
});
|
|
expect(el.textContent).toContain("Destructive");
|
|
// First click opens the confirm gate rather than deciding.
|
|
clickButtonWithText(el, "Cancel the tree");
|
|
expect(onDecide).not.toHaveBeenCalled();
|
|
expect(el.textContent).toContain("to confirm");
|
|
const confirm = [...el.querySelectorAll("button")].find((b) => b.textContent?.match(/Cancel \d+ issue/));
|
|
expect(confirm?.disabled).toBe(true);
|
|
});
|
|
|
|
it("renders decided result rows with entity links", () => {
|
|
const el = render({
|
|
decision: mkDecision({ status: "decided", executionStatus: "succeeded", chosenOptionId: "create" }),
|
|
executions: [
|
|
exec({ effectIndex: 0, effectType: "create_issue", status: "executed", result: { issueId: "issue-new" } }),
|
|
exec({ effectIndex: 1, effectType: "update_issue_status", status: "executed", result: { issueId: "issue-target", status: "done" } }),
|
|
],
|
|
});
|
|
expect(el.textContent).toContain("Decided");
|
|
expect(el.textContent).toContain("Created PAP-999");
|
|
expect(el.textContent).toContain("Set PAP-456 to done");
|
|
expect([...el.querySelectorAll("a")].some((a) => a.getAttribute("href") === "/PAP/issues/PAP-999")).toBe(true);
|
|
// No option buttons on a terminal decision.
|
|
expect([...el.querySelectorAll("button")].length).toBe(0);
|
|
});
|
|
|
|
it("surfaces failure cause and the fail-closed / re-propose guidance on partial", () => {
|
|
const el = render({
|
|
decision: mkDecision({ status: "decided", executionStatus: "partial", chosenOptionId: "create" }),
|
|
executions: [
|
|
exec({ effectIndex: 0, effectType: "comment_on_issue", status: "executed" }),
|
|
exec({ effectIndex: 1, effectType: "update_issue_status", status: "failed", error: "deny_decision_intersection" }),
|
|
],
|
|
});
|
|
expect(el.textContent).toContain("Partial");
|
|
expect(el.textContent).toContain("permission boundary");
|
|
expect(el.textContent).toContain("may already have been applied");
|
|
expect(el.textContent).toContain("re-propose");
|
|
});
|
|
|
|
it("marks skipped effects as target-changed on a failed decision", () => {
|
|
const el = render({
|
|
decision: mkDecision({ status: "decided", executionStatus: "failed", chosenOptionId: "cancel" }),
|
|
executions: [exec({ effectIndex: 0, effectType: "cancel_issue_tree", status: "skipped", error: "target_changed" })],
|
|
});
|
|
expect(el.textContent).toContain("Failed");
|
|
expect(el.textContent).toContain("target changed since proposal");
|
|
});
|
|
|
|
it("renders expired and dismissed terminal states", () => {
|
|
const expired = render({ decision: mkDecision({ status: "expired", metadata: { expiredReason: "ttl" } }) });
|
|
expect(expired.textContent).toContain("Expired");
|
|
expect(expired.textContent).toContain("window closed");
|
|
act(() => root?.unmount());
|
|
container?.remove();
|
|
|
|
const dismissed = render({ decision: mkDecision({ status: "decided", executionStatus: "succeeded", chosenOptionId: "dismissed", metadata: { dismissed: true } }), executions: [] });
|
|
expect(dismissed.textContent).toContain("Dismissed");
|
|
expect(dismissed.textContent).toContain("no effects were run");
|
|
});
|
|
|
|
it("explains when a decision expires because its targets completed", () => {
|
|
const expired = render({
|
|
decision: mkDecision({ status: "expired", metadata: { expiredReason: "target_completed" } }),
|
|
});
|
|
expect(expired.textContent).toContain("target issues were completed");
|
|
expect(expired.textContent).not.toContain("expiry deadline");
|
|
});
|
|
});
|