paperclip/ui/src/components/IssueRecoveryActionCard.tes...

1107 lines
43 KiB
TypeScript

// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent, IssueRecoveryAction } from "@paperclipai/shared";
import { IssueRecoveryActionCard, deriveRecoveryCardState } from "./IssueRecoveryActionCard";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act<T>(callback: () => T): T {
let result: T | undefined;
flushSync(() => {
result = callback();
});
const maybePromise = result as unknown as PromiseLike<unknown>;
if (result && typeof maybePromise.then === "function") {
throw new TypeError("This test act shim only supports synchronous callbacks.");
}
return result as T;
}
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
act(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
const ownerAgent: Agent = {
id: "11111111-1111-1111-1111-111111111111",
companyId: "company-1",
name: "ClaudeCoder",
role: "engineer",
status: "idle",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
urlKey: "claudecoder",
} as unknown as Agent;
const returnAgent: Agent = {
...ownerAgent,
id: "22222222-2222-2222-2222-222222222222",
name: "CodexCoder",
urlKey: "codexcoder",
} as Agent;
function buildAction(overrides: Partial<IssueRecoveryAction> = {}): IssueRecoveryAction {
return {
id: "00000000-0000-0000-0000-0000000000aa",
companyId: "company-1",
sourceIssueId: "00000000-0000-0000-0000-0000000000ff",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: ownerAgent.id,
ownerUserId: null,
previousOwnerAgentId: returnAgent.id,
returnOwnerAgentId: returnAgent.id,
cause: "missing_disposition",
fingerprint: "fp",
evidence: {
summary: "Run finished but no disposition was chosen.",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
},
nextAction: "Choose and record a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: "2026-05-09T19:30:00.000Z",
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-09T19:30:00.000Z",
updatedAt: "2026-05-09T19:30:00.000Z",
...overrides,
};
}
describe("deriveRecoveryCardState", () => {
it("maps active missing_disposition to needed", () => {
expect(deriveRecoveryCardState(buildAction())).toBe("needed");
});
it("maps active_run_watchdog to observe_only", () => {
expect(deriveRecoveryCardState(buildAction({ kind: "active_run_watchdog" }))).toBe("observe_only");
});
it("maps escalated status to escalated", () => {
expect(deriveRecoveryCardState(buildAction({ status: "escalated" }))).toBe("escalated");
});
it("maps resolved/cancelled to resolved", () => {
expect(deriveRecoveryCardState(buildAction({ status: "resolved" }))).toBe("resolved");
expect(deriveRecoveryCardState(buildAction({ status: "cancelled" }))).toBe("resolved");
});
});
describe("IssueRecoveryActionCard", () => {
it("renders state and kind attributes with owner names and the recorded next action", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction()}
agentMap={new Map([
[ownerAgent.id, ownerAgent],
[returnAgent.id, returnAgent],
])}
onResolve={() => {}}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section).not.toBeNull();
expect(section?.getAttribute("data-recovery-state")).toBe("needed");
expect(section?.getAttribute("data-recovery-kind")).toBe("missing_disposition");
expect(node.textContent).toContain("RECOVERY NEEDED");
expect(node.textContent).toContain("Missing Disposition");
expect(node.textContent).toContain(
"This task's run finished, but no next step was chosen. Choose what happens next — try the task again, mark it done, or send it for review.",
);
expect(node.textContent).toContain("An agent will be asked to choose the next step");
expect(node.textContent).toContain("ClaudeCoder");
expect(node.textContent).toContain("CodexCoder");
expect(node.textContent).toContain("Choose and record a valid issue disposition.");
});
it("renders observe_only tone for active_run_watchdog", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ kind: "active_run_watchdog" })} />,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-state")).toBe("observe_only");
expect(node.textContent).toContain("OBSERVING ACTIVE RUN");
expect(node.textContent).toContain(
"The active run has been silent. Recovery is observing without interrupting it.",
);
});
it.each(["active", "escalated", "resolved"] as const)("keeps %s runner recovery in the run log without a card", status => {
const node = render(<IssueRecoveryActionCard action={buildAction({
kind: "active_run_watchdog", cause: "uncertain_external_action", status, ownerType: "board",
})} />);
expect(node.textContent).toBe("");
expect(node.querySelector("section")).toBeNull();
});
it.each(["active", "escalated"] as const)(
"describes a %s board-owned watchdog as a human decision, not a live run",
(status) => {
const node = render(
<IssueRecoveryActionCard
action={buildAction({
kind: "active_run_watchdog",
status,
ownerType: "board",
ownerAgentId: null,
wakePolicy: null,
})}
/>,
);
expect(node.textContent).toContain(
"This recovery needs a human decision. Review the recorded failure and choose the next step.",
);
expect(node.textContent).not.toContain("The active run has been silent");
expect(node.textContent).not.toContain("observing without interrupting");
expect(
node.querySelector("[data-testid='recovery-action-resolve-trigger']"),
).toBeNull();
},
);
it("retains the existing authorized controls for a board-owned watchdog", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildAction({
kind: "active_run_watchdog",
ownerType: "board",
ownerAgentId: null,
wakePolicy: null,
})}
onResolve={onResolve}
/>,
);
click(
node.querySelector("[data-testid='recovery-action-resolve-trigger']"),
);
expect(document.body.textContent).not.toContain("False positive");
click(
[...document.body.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Try again"),
) ?? null,
);
expect(onResolve).toHaveBeenCalledExactlyOnceWith("todo");
});
it("explains issue_graph_liveness in plain language", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction({ kind: "issue_graph_liveness", cause: "issue_graph_liveness" })}
/>,
);
expect(node.textContent).toContain("Task Needs Next Step");
expect(node.textContent).toContain(
"Paperclip could not find a clear next step for this open task. Choose whether to continue work, send it for review, mark it done, or record what is blocking it.",
);
});
it("falls back to an em dash when no evidence summary is available", () => {
const node = render(<IssueRecoveryActionCard action={buildAction({ evidence: {} })} />);
expect(node.textContent).toContain("—");
});
it("renders workspace_validation with its kind attribute and the recorded next action", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction({
kind: "workspace_validation",
cause: "workspace_validation_failed",
nextAction:
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
wakePolicy: { type: "manual_repair_required" },
evidence: {
recoveryCause: "workspace_validation_failed",
latestRunErrorCode: "workspace_validation_failed",
},
})}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-kind")).toBe("workspace_validation");
expect(node.textContent).toContain("Workspace Validation");
expect(node.textContent).toContain(
"Paperclip stopped this run because the task's git workspace could not be validated.",
);
expect(node.textContent).toContain("Repair the source issue workspace link");
});
it("renders a human evidence summary as prose, not a mono log line", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction({
kind: "stranded_assigned_issue",
cause: "stranded_assigned_issue",
evidence: {
summary: "Unmanaged background task stopped; no durable live path.",
latestRunStatus: "failed",
latestRunErrorCode: "unmanaged_background_task_stopped",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
},
})}
/>,
);
const summary = Array.from(node.querySelectorAll("span")).find((el) =>
el.textContent === "Unmanaged background task stopped; no durable live path.",
);
expect(summary).toBeTruthy();
expect(summary?.className).toContain("text-xs");
expect(summary?.className).not.toContain("font-mono");
expect(node.textContent).toContain(
"To get it moving, choose what happens next — try the task again, mark it done, or send it for review.",
);
});
it("keeps code-shaped evidence (error code, no summary) in the mono treatment", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction({
kind: "workspace_validation",
cause: "workspace_validation_failed",
evidence: {
latestRunErrorCode: "workspace_validation_failed",
},
})}
/>,
);
const code = Array.from(node.querySelectorAll("span")).find((el) =>
el.textContent === "workspace_validation_failed",
);
expect(code).toBeTruthy();
expect(code?.className).toContain("font-mono");
});
it("renders the resolved state and outcome when resolved", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ status: "resolved", outcome: "restored", resolvedAt: "2026-05-09T19:35:00.000Z" })} />,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-state")).toBe("resolved");
expect(node.textContent).toContain("RECOVERY RESOLVED");
expect(node.textContent).toContain("Recovery resolved as restored.");
expect(node.textContent).toContain("Resolved as restored");
});
it("calls resolve with todo and does not offer delegated recovery", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Try again");
expect(document.body.textContent).toContain("Mark task done");
expect(document.body.textContent).not.toContain("Mark blocked");
expect(document.body.textContent).not.toContain("Delegate follow-up issue");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Try again")) ?? null);
expect(onResolve).toHaveBeenCalledWith("todo");
});
it("does not offer blocked recovery resolution without a blocker selection flow", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} canFalsePositive />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Try again");
expect(document.body.textContent).toContain("Mark task done");
expect(document.body.textContent).toContain("Send for review");
expect(document.body.textContent).toContain("False positive, done");
expect(document.body.textContent).toContain("False positive, review");
expect(document.body.textContent).not.toContain("Mark blocked");
});
it("hides false-positive options unless canFalsePositive is set", () => {
const first = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
);
click(first.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).not.toContain("False positive");
act(() => root?.unmount());
root = null;
container?.remove();
container = null;
const onResolve = vi.fn();
const second = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canFalsePositive />,
);
click(second.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("False positive, done");
expect(document.body.textContent).toContain("False positive, review");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("False positive, done")) ?? null);
expect(onResolve).toHaveBeenCalledWith("false_positive_done");
});
});
function buildWorkspaceValidationAction(
overrides: {
action?: Partial<IssueRecoveryAction>;
provenance?: Record<string, unknown>;
workspaceValidation?: Record<string, unknown>;
} = {},
): IssueRecoveryAction {
const provenance = {
expectedHeadSha: "aaaaaaaaaaaa11112222",
actualHeadSha: "bbbbbbbbbbbb33334444",
ancestryVerdict: "diverged",
plainLanguageReason:
'The recorded branch "PAP-522-recorded" is not an ancestor of the checked-out branch "nleach/PAP-1405-live", so Paperclip cannot prove a forward-only reconciliation.',
...overrides.provenance,
};
return buildAction({
kind: "workspace_validation",
cause: "workspace_validation_failed",
evidence: {
workspaceValidation: {
reason: "git_worktree_branch_incoherence",
expectedBranch: "PAP-522-recorded",
actualBranch: "nleach/PAP-1405-live",
cleanliness: "clean",
provenance,
...overrides.workspaceValidation,
},
},
...overrides.action,
});
}
describe("IssueRecoveryActionCard workspace_validation divergence", () => {
it("renders the divergence diagnosis with branches, shas, verdict and plain-language reason", () => {
const node = render(<IssueRecoveryActionCard action={buildWorkspaceValidationAction()} />);
const diagnosis = node.querySelector("[data-testid='recovery-divergence-diagnosis']");
expect(diagnosis).not.toBeNull();
const text = diagnosis?.textContent ?? "";
expect(text).toContain("Divergence diagnosis");
expect(text).toContain("Expected · recorded");
expect(text).toContain("Live · checked out");
expect(text).toContain("PAP-522-recorded");
expect(text).toContain("nleach/PAP-1405-live");
// shortened shas (10 chars)
expect(text).toContain("aaaaaaaaaa");
expect(text).toContain("bbbbbbbbbb");
expect(text).toContain("cannot prove a forward-only reconciliation");
expect(node.querySelector("[data-testid='recovery-ancestry-verdict']")).not.toBeNull();
});
it("labels each ancestry verdict", () => {
const diverged = render(<IssueRecoveryActionCard action={buildWorkspaceValidationAction()} />);
expect(diverged.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toBe("Diverged");
const ancestor = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "ancestor" } })}
/>,
);
expect(ancestor.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toBe("Forward-only");
const unknown = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "unknown" } })}
/>,
);
expect(unknown.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toBe("Ancestry unknown");
});
it("does not render a divergence diagnosis for non-incoherence workspace failures", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({
workspaceValidation: { reason: "workspace_link_missing", provenance: undefined },
})}
/>,
);
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).toBeNull();
});
it("offers the re-issue action and passes the live branch as the base ref", () => {
const onReissueIsolated = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction()}
onReissueIsolated={onReissueIsolated}
/>,
);
click(node.querySelector("[data-testid='recovery-action-reissue-trigger']"));
click(document.body.querySelector("[data-testid='recovery-action-reissue-confirm']"));
expect(onReissueIsolated).toHaveBeenCalledWith({
baseRef: "nleach/PAP-1405-live",
liveBranch: "nleach/PAP-1405-live",
liveHeadSha: "bbbbbbbbbbbb33334444",
expectedBranch: "PAP-522-recorded",
});
});
it("falls back to the live HEAD sha as base ref when the branch is detached", () => {
const onReissueIsolated = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ workspaceValidation: { actualBranch: null } })}
onReissueIsolated={onReissueIsolated}
/>,
);
click(node.querySelector("[data-testid='recovery-action-reissue-trigger']"));
click(document.body.querySelector("[data-testid='recovery-action-reissue-confirm']"));
expect(onReissueIsolated).toHaveBeenCalledWith(
expect.objectContaining({ baseRef: "bbbbbbbbbbbb33334444", liveBranch: null }),
);
});
it("does not offer the re-issue action for non-workspace kinds", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction()} onReissueIsolated={() => {}} />,
);
expect(node.querySelector("[data-testid='recovery-action-reissue-trigger']")).toBeNull();
});
it("disables the re-issue action while a re-issue is pending", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction()}
onReissueIsolated={() => {}}
reissuePending
/>,
);
const trigger = node.querySelector<HTMLButtonElement>("[data-testid='recovery-action-reissue-trigger']");
expect(trigger?.disabled).toBe(true);
});
});
function setTextareaValue(element: HTMLTextAreaElement | null, value: string) {
if (!element) throw new Error("Expected a textarea to exist");
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
act(() => {
setter?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
});
}
describe("IssueRecoveryActionCard W7 reconcile actions", () => {
it("offers 'Reconcile forward & continue' only for an ancestor verdict and calls the handler", () => {
const onReconcileForward = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "ancestor" } })}
onReconcileForward={onReconcileForward}
/>,
);
const button = node.querySelector("[data-testid='recovery-action-reconcile-forward']");
expect(button).not.toBeNull();
click(button);
expect(onReconcileForward).toHaveBeenCalledTimes(1);
});
it("hides 'Reconcile forward & continue' when the verdict is not an ancestor", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "diverged" } })}
onReconcileForward={() => {}}
/>,
);
expect(node.querySelector("[data-testid='recovery-action-reconcile-forward']")).toBeNull();
});
it("disables reconcile-forward while a reconcile is pending", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "ancestor" } })}
onReconcileForward={() => {}}
reconcilePending
/>,
);
const button = node.querySelector<HTMLButtonElement>("[data-testid='recovery-action-reconcile-forward']");
expect(button?.disabled).toBe(true);
});
it("never renders the break-glass action for a non-permitted operator", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction()}
onBreakGlassOverride={() => {}}
canBreakGlass={false}
/>,
);
expect(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")).toBeNull();
});
it("break-glass restates the divergence and gates the override behind a required reason", () => {
const onBreakGlassOverride = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction()}
onBreakGlassOverride={onBreakGlassOverride}
canBreakGlass
/>,
);
click(node.querySelector("[data-testid='recovery-action-breakglass-trigger']"));
// The confirm step restates the divergence: both branches and both short SHAs.
const restated = document.body.querySelector("[data-testid='recovery-breakglass-restated-divergence']");
const restatedText = restated?.textContent ?? "";
expect(restatedText).toContain("PAP-522-recorded");
expect(restatedText).toContain("nleach/PAP-1405-live");
expect(restatedText).toContain("aaaaaaaaaa");
expect(restatedText).toContain("bbbbbbbbbb");
// The override is disabled until a non-empty reason is recorded.
const confirm = document.body.querySelector<HTMLButtonElement>(
"[data-testid='recovery-action-breakglass-confirm']",
);
expect(confirm?.disabled).toBe(true);
click(confirm);
expect(onBreakGlassOverride).not.toHaveBeenCalled();
// Whitespace-only reason does not enable it.
setTextareaValue(
document.body.querySelector<HTMLTextAreaElement>("[data-testid='recovery-breakglass-reason']"),
" ",
);
expect(
document.body.querySelector<HTMLButtonElement>("[data-testid='recovery-action-breakglass-confirm']")?.disabled,
).toBe(true);
// A real reason enables the override and is passed (trimmed) to the handler.
setTextareaValue(
document.body.querySelector<HTMLTextAreaElement>("[data-testid='recovery-breakglass-reason']"),
" Verified live branch is safe to adopt. ",
);
const enabledConfirm = document.body.querySelector<HTMLButtonElement>(
"[data-testid='recovery-action-breakglass-confirm']",
);
expect(enabledConfirm?.disabled).toBe(false);
click(enabledConfirm);
expect(onBreakGlassOverride).toHaveBeenCalledWith("Verified live branch is safe to adopt.");
});
it("does not offer reconcile actions for non-workspace recovery kinds", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction()}
onReconcileForward={() => {}}
onBreakGlassOverride={() => {}}
canBreakGlass
/>,
);
expect(node.querySelector("[data-testid='recovery-action-reconcile-forward']")).toBeNull();
expect(node.querySelector("[data-testid='recovery-action-breakglass-trigger']")).toBeNull();
});
});
function buildDirtyDivergenceAction(
overrides: {
action?: Partial<IssueRecoveryAction>;
provenance?: Record<string, unknown>;
workspaceValidation?: Record<string, unknown>;
} = {},
): IssueRecoveryAction {
return buildWorkspaceValidationAction({
...overrides,
workspaceValidation: {
cleanliness: "dirty",
statusEntryCount: 3,
dirtyPathSample: ["src/app.ts", "README.md"],
sourceIdentifier: "PAP-1405",
...overrides.workspaceValidation,
},
});
}
describe("IssueRecoveryActionCard repair workspace (quarantine_restore)", () => {
it("offers the repair action only for a dirty divergence", () => {
const cleanNode = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ workspaceValidation: { cleanliness: "clean" } })}
onQuarantineRestore={() => {}}
/>,
);
expect(cleanNode.querySelector("[data-testid='recovery-action-repair-trigger']")).toBeNull();
const dirtyNode = render(
<IssueRecoveryActionCard action={buildDirtyDivergenceAction()} onQuarantineRestore={() => {}} />,
);
expect(dirtyNode.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
});
it("does not offer the repair action without a handler or for non-workspace kinds", () => {
const noHandler = render(<IssueRecoveryActionCard action={buildDirtyDivergenceAction()} />);
expect(noHandler.querySelector("[data-testid='recovery-action-repair-trigger']")).toBeNull();
const nonWorkspace = render(
<IssueRecoveryActionCard action={buildAction()} onQuarantineRestore={() => {}} />,
);
expect(nonWorkspace.querySelector("[data-testid='recovery-action-repair-trigger']")).toBeNull();
});
it("confirm popover restates the dirty count, live branch, rescue branch and recorded branch, then fires the handler", () => {
const onQuarantineRestore = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction()}
onQuarantineRestore={onQuarantineRestore}
/>,
);
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
const restated = document.body.querySelector("[data-testid='recovery-repair-restated']");
const text = restated?.textContent ?? "";
expect(
document.body.querySelector("[data-testid='recovery-repair-dirty-count']")?.textContent,
).toBe("3 uncommitted changes");
// live branch is named in the restated summary, left untouched
expect(text).toContain("nleach/PAP-1405-live");
expect(text).toContain("(left untouched)");
// rescue branch preview mirrors the server naming (prefix + timestamp marker)
expect(
document.body.querySelector("[data-testid='recovery-repair-rescue-branch']")?.textContent,
).toContain("paperclip/rescue/PAP-1405/");
// recorded branch to be restored
expect(text).toContain("PAP-522-recorded");
// No reason field is present — the operation is lossless.
expect(document.body.querySelector("textarea")).toBeNull();
click(document.body.querySelector("[data-testid='recovery-action-repair-confirm']"));
expect(onQuarantineRestore).toHaveBeenCalledTimes(1);
});
it("singularizes a one-file dirty count", () => {
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction({ workspaceValidation: { statusEntryCount: 1 } })}
onQuarantineRestore={() => {}}
/>,
);
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
expect(
document.body.querySelector("[data-testid='recovery-repair-dirty-count']")?.textContent,
).toBe("1 uncommitted change");
});
it("disables the repair trigger while a quarantine-restore is pending", () => {
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction()}
onQuarantineRestore={() => {}}
quarantineRestorePending
/>,
);
expect(
node.querySelector<HTMLButtonElement>("[data-testid='recovery-action-repair-trigger']")?.disabled,
).toBe(true);
});
it("in the contended case disables repair, explains the claimant, and recommends re-issue", () => {
const onQuarantineRestore = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction({
workspaceValidation: {
contention: {
claimedByWorkspaceId: "ws-99",
claimedByIssueId: "issue-99",
claimedByIssueIdentifier: "PAP-9001",
activeRun: { id: "run-9001", status: "running", issueId: "issue-99", issueIdentifier: "PAP-9001" },
},
},
})}
onQuarantineRestore={onQuarantineRestore}
onReissueIsolated={() => {}}
/>,
);
// Diagnosis gains a claimant line naming the claiming issue.
const notice = node.querySelector("[data-testid='recovery-contention-notice']");
expect(notice?.textContent).toContain("Worktree claimed by");
expect(notice?.textContent).toContain("PAP-9001");
expect(notice?.textContent).toContain("(active run)");
// The repair control is present but disabled, with the claimant as the explanation.
const disabled = node.querySelector("[data-testid='recovery-action-repair-disabled']");
expect(disabled).not.toBeNull();
const trigger = disabled?.querySelector<HTMLButtonElement>(
"[data-testid='recovery-action-repair-trigger']",
);
expect(trigger?.disabled).toBe(true);
expect(disabled?.textContent).toContain(
"Held by PAP-9001 — re-issue on an isolated workspace instead.",
);
// Clicking the disabled control never fires the repair.
click(trigger ?? null);
expect(onQuarantineRestore).not.toHaveBeenCalled();
// Re-issue is surfaced as the recommended action.
expect(node.querySelector("[data-testid='recovery-reissue-recommended']")).not.toBeNull();
expect(
node
.querySelector("[data-testid='recovery-action-reissue-trigger']")
?.getAttribute("data-recommended"),
).toBe("true");
});
it("compact variant drops the metadata table but keeps the diagnosis and repair action", () => {
const node = render(
<IssueRecoveryActionCard
action={buildDirtyDivergenceAction()}
onQuarantineRestore={() => {}}
variant="compact"
/>,
);
// Metadata rows (e.g. the Owner/Next action table) are dropped in compact mode.
expect(node.textContent).not.toContain("Choose and record a valid issue disposition.");
// The divergence diagnosis and repair action still render.
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
});
});
describe("IssueRecoveryActionCard owner-sticky retry lineage", () => {
const NOW = new Date("2026-08-18T12:00:00.000Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function at(offsetMs: number) {
return new Date(NOW.getTime() + offsetMs).toISOString();
}
const bothAgents = new Map([
[ownerAgent.id, ownerAgent],
[returnAgent.id, returnAgent],
]);
/** Phase 1 — the original owner (CodexCoder) is retrying itself. */
function buildSourceLaneAction(overrides: Partial<IssueRecoveryAction> = {}) {
return buildAction({
kind: "deliberate_wait_without_target",
cause: "deliberate_wait_without_target",
ownerAgentId: returnAgent.id,
previousOwnerAgentId: returnAgent.id,
returnOwnerAgentId: returnAgent.id,
nextAction:
"The original owner must replace the parked summary with a terminal, live, blocked, monitored, or typed waiting disposition.",
wakePolicy: {
type: "bounded_owner_disposition_repair",
retryAgentId: returnAgent.id,
attempt: 2,
maxAttempts: 5,
baseBackoffMs: 60_000,
jitterMs: 3_000,
retryAt: at(3 * 60_000),
scheduledRunId: "00000000-0000-0000-0000-0000000000b1",
},
attemptCount: 2,
maxAttempts: 5,
timeoutAt: at(3 * 60_000),
...overrides,
});
}
/** Phase 2 — a manager (ClaudeCoder) repairs the path; CodexCoder keeps the task. */
function buildRecoveryLaneAction(overrides: Partial<IssueRecoveryAction> = {}) {
return buildSourceLaneAction({
ownerAgentId: ownerAgent.id,
nextAction:
"Repair the source issue disposition or request an explicit reassignment decision without taking source ownership.",
evidence: {
summary: "Run finished but no disposition was chosen.",
sourceAttemptCount: 5,
sourceMaxAttempts: 5,
},
wakePolicy: {
type: "bounded_recovery_owner",
ownerAgentId: ownerAgent.id,
attempt: 1,
maxAttempts: 3,
retryAt: at(60_000),
scheduledRunId: "00000000-0000-0000-0000-0000000000b2",
preservesSourceAssignee: true,
},
attemptCount: 1,
maxAttempts: 3,
timeoutAt: at(60_000),
...overrides,
});
}
it("stays quiet and names the retry lane while the original owner is being retried", () => {
const node = render(
<IssueRecoveryActionCard action={buildSourceLaneAction()} agentMap={bothAgents} />,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(section?.getAttribute("data-recovery-kind")).toBe("deliberate_wait_without_target");
expect(section?.getAttribute("data-recovery-lane")).toBe("source_owner");
expect(node.textContent).toContain("RECOVERY IN PROGRESS");
expect(node.textContent).not.toContain("RECOVERY NEEDED");
expect(node.textContent).toContain("Wait Without A Target");
expect(node.textContent).toContain("The task stays with its owner, and no action is needed yet.");
expect(node.textContent).toContain("Paperclip is retrying the original owner");
});
it("shows the five-attempt budget and the next due time", () => {
const node = render(
<IssueRecoveryActionCard action={buildSourceLaneAction()} agentMap={bothAgents} />,
);
const progress = node.querySelector("[data-testid='recovery-retry-progress']");
expect(progress).not.toBeNull();
expect(progress?.getAttribute("data-recovery-lane")).toBe("source_owner");
expect(progress?.getAttribute("data-recovery-attempt")).toBe("2");
expect(progress?.getAttribute("data-recovery-max-attempts")).toBe("5");
expect(progress?.textContent).toContain("Attempt 2 of 5");
expect(node.querySelector("[data-testid='recovery-next-retry']")?.textContent).toBe(
"Next try in 3m",
);
});
it("keeps the source owner and the recovery owner as separate roles", () => {
const node = render(
<IssueRecoveryActionCard action={buildRecoveryLaneAction()} agentMap={bothAgents} />,
);
const sourceOwner = node.querySelector("[data-testid='recovery-source-owner']");
const recoveryOwner = node.querySelector("[data-testid='recovery-recovery-owner']");
// CodexCoder is the original owner and keeps the deliverable.
expect(sourceOwner?.textContent).toContain("CodexCoder");
expect(sourceOwner?.textContent).toContain("keeps this task");
// ClaudeCoder only repairs the path.
expect(recoveryOwner?.textContent).toContain("ClaudeCoder");
expect(recoveryOwner?.textContent).toContain("repairs the next step only");
expect(recoveryOwner?.textContent).not.toContain("CodexCoder");
expect(node.textContent).toContain(
"the task itself still belongs to its original owner",
);
});
it("labels the source lane as the owner retrying itself", () => {
const node = render(
<IssueRecoveryActionCard action={buildSourceLaneAction()} agentMap={bothAgents} />,
);
expect(
node.querySelector("[data-testid='recovery-recovery-owner']")?.textContent,
).toContain("Original owner — retrying itself");
});
it("reports the spent source attempts once the manager lane opens", () => {
const node = render(
<IssueRecoveryActionCard action={buildRecoveryLaneAction()} agentMap={bothAgents} />,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-lane")).toBe("recovery_owner");
expect(section?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(
node.querySelector("[data-testid='recovery-source-attempts']")?.textContent,
).toContain("The original owner used 5 of 5 automatic attempts.");
expect(
node.querySelector("[data-testid='recovery-retry-progress']")?.textContent,
).toContain("Attempt 1 of 3");
});
it("warns strongly only once the automatic path is exhausted", () => {
const node = render(
<IssueRecoveryActionCard
action={buildSourceLaneAction({
wakePolicy: {
type: "bounded_owner_disposition_repair",
retryAgentId: returnAgent.id,
attempt: 5,
maxAttempts: 5,
retryAt: at(-60_000),
},
attemptCount: 5,
})}
agentMap={bothAgents}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-state")).toBe("needed");
expect(node.textContent).toContain("RECOVERY NEEDED");
expect(node.textContent).toContain("has used every automatic repair attempt");
expect(node.querySelector("[data-testid='recovery-next-retry']")?.textContent).toBe(
"Automatic retries used up",
);
// The follow-up line must not keep promising a retry that will never run, and the
// generic timeout chip must not reintroduce a stale due time next to it.
expect(node.textContent).toContain("Automatic retries are finished — a decision is needed");
expect(node.textContent).not.toContain("Paperclip is retrying the original owner");
expect(node.textContent).not.toContain("Times out");
});
it("warns strongly when the stored retry came due and never ran", () => {
// PAP-17561: this exact shape rendered "Recovery in progress · Attempt 1 of 5 · Next try
// 5m ago" — a calm card over a lane nothing was working on.
const node = render(
<IssueRecoveryActionCard
action={buildSourceLaneAction({
wakePolicy: {
type: "bounded_owner_disposition_repair",
retryAgentId: returnAgent.id,
attempt: 1,
maxAttempts: 5,
retryAt: at(-5 * 60_000),
scheduledRunId: "00000000-0000-0000-0000-0000000000b2",
},
attemptCount: 1,
timeoutAt: at(-5 * 60_000),
})}
agentMap={bothAgents}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-state")).toBe("needed");
expect(node.textContent).toContain("RECOVERY NEEDED");
expect(node.textContent).not.toContain("RECOVERY IN PROGRESS");
const retry = node.querySelector("[data-testid='recovery-next-retry']");
expect(retry?.textContent).toBe("Retry missed 5m ago");
expect(retry?.getAttribute("data-recovery-retry-expired")).toBe("true");
// Nothing may still read as an upcoming attempt or as needing no action.
expect(node.textContent).not.toContain("Next try");
expect(node.textContent).not.toContain("no action is needed yet");
expect(node.textContent).toContain("came due and did not run");
expect(node.textContent).toContain("The scheduled retry did not run");
// The repair lane still must not move the deliverable off its original owner.
expect(node.querySelector("[data-testid='recovery-source-owner']")?.textContent).toContain(
"keeps this task",
);
});
it("stays quiet when the overdue attempt is a verified live run", () => {
const node = render(
<IssueRecoveryActionCard
action={buildSourceLaneAction({
wakePolicy: {
type: "bounded_owner_disposition_repair",
retryAgentId: returnAgent.id,
attempt: 2,
maxAttempts: 5,
retryAt: at(-5 * 60_000),
scheduledRunId: "00000000-0000-0000-0000-0000000000b2",
},
timeoutAt: at(-5 * 60_000),
})}
agentMap={bothAgents}
scheduledRetry={{
runId: "00000000-0000-0000-0000-0000000000b2",
status: "running",
agentId: returnAgent.id,
agentName: returnAgent.name,
retryOfRunId: null,
scheduledRetryAt: at(-5 * 60_000),
scheduledRetryAttempt: 2,
scheduledRetryReason: null,
}}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(node.querySelector("[data-testid='recovery-next-retry']")?.textContent).toBe(
"Attempt running now",
);
expect(node.textContent).not.toContain("Retry missed");
});
it("keeps timing in the retry-progress row only while a lane is live", () => {
const node = render(
<IssueRecoveryActionCard action={buildSourceLaneAction()} agentMap={bothAgents} />,
);
expect(node.textContent).toContain("Paperclip is retrying the original owner");
expect(node.textContent).not.toContain("Times out");
});
it("shows a current-policy board recovery action without implying the board owns the task", () => {
const node = render(
<IssueRecoveryActionCard
action={buildSourceLaneAction({
status: "active",
ownerType: "board",
ownerAgentId: null,
evidence: {
sourceAttemptCount: 5,
sourceMaxAttempts: 5,
routingPolicy: "board_escalation_no_takeover_v1",
},
wakePolicy: {
type: "board_escalation",
reason: "unchanged_source_state_exhausted",
preservesSourceAssignee: true,
},
attemptCount: 5,
maxAttempts: null,
timeoutAt: null,
})}
agentMap={bothAgents}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("data-recovery-state")).toBe("needed");
expect(section?.getAttribute("data-recovery-lane")).toBe("board");
expect(node.textContent).toContain("Automatic recovery is exhausted");
expect(node.textContent).toContain("Board decision required");
const recoveryOwner = node.querySelector("[data-testid='recovery-recovery-owner']");
expect(recoveryOwner?.textContent).toContain("Board");
expect(recoveryOwner?.textContent).toContain("decides the next step only");
expect(
node.querySelector("[data-testid='recovery-source-owner']")?.textContent,
).toContain("CodexCoder");
});
it("leaves kinds without a bounded lineage on the original single owner row", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction()} agentMap={bothAgents} />,
);
expect(node.querySelector("section[aria-label]")?.getAttribute("data-recovery-lane")).toBeNull();
expect(node.querySelector("[data-testid='recovery-retry-progress']")).toBeNull();
expect(node.querySelector("[data-testid='recovery-source-owner']")).toBeNull();
expect(node.textContent).toContain("→ Returns to:");
});
});