diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 317f0cce500..fbd62c6e839 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; import type { AnchorHTMLAttributes, ReactElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { ISSUE_RECOVERY_ACTION_KINDS } from "@paperclipai/shared"; import type { Agent, IssueRecoveryAction } from "@paperclipai/shared"; import { IssueRecoveryActionCard, deriveRecoveryCardState } from "./IssueRecoveryActionCard"; @@ -168,6 +169,30 @@ describe("IssueRecoveryActionCard", () => { ); }); + // PAP-17058: PAP-17057 added a recovery action kind without a UI label, which + // only surfaced as a tsc failure once both branches met. Assert every kind in + // the shared union renders its own copy so a new kind cannot land unlabelled + // or silently share another kind's headline. + it("gives every shared recovery action kind a distinct label and headline", () => { + const headlines = new Map(); + for (const kind of ISSUE_RECOVERY_ACTION_KINDS) { + const node = render(); + const text = node.textContent ?? ""; + expect(text.trim(), `${kind} rendered no copy`).not.toBe(""); + headlines.set(kind, text); + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + } + expect(headlines.size).toBe(ISSUE_RECOVERY_ACTION_KINDS.length); + const deliberateWait = headlines.get("deliberate_wait_without_target") ?? ""; + expect(deliberateWait).toContain("Waiting On Nothing"); + expect(deliberateWait).toContain("no reviewer, blocker, or open subtask to wait on"); + // The generic missing_disposition copy must not be what a named kind shows. + expect(deliberateWait).not.toContain("no next step was chosen"); + }); + it("explains issue_graph_liveness in plain language", () => { const node = render( = { missing_disposition: "Missing Disposition", + deliberate_wait_without_target: "Waiting On Nothing", stranded_assigned_issue: "Stranded Task", workspace_validation: "Workspace Validation", configuration_validation: "Configuration Validation", @@ -126,6 +127,8 @@ const KIND_LABEL: Record = { const KIND_HEADLINE: Record = { missing_disposition: "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.", + deliberate_wait_without_target: + "This task's run stopped to wait for something, but there is no reviewer, blocker, or open subtask to wait on. Paperclip is asking the original owner to pick a real next step.", stranded_assigned_issue: "Paperclip retried this task's last run, but there is still no queued run, reviewer, blocker, or other next owner. To get it moving, choose what happens next — try the task again, mark it done, or send it for review.", workspace_validation: diff --git a/ui/src/lib/recovery-display.test.ts b/ui/src/lib/recovery-display.test.ts index c1c36d37d2b..071f6412c1e 100644 --- a/ui/src/lib/recovery-display.test.ts +++ b/ui/src/lib/recovery-display.test.ts @@ -103,6 +103,25 @@ describe("deriveRecoveryDisplayState", () => { expect(deriveRecoveryDisplayState(base, {})).toBe("needed"); }); + // PAP-17057 added this kind: a run stopped to wait on nothing, so the original + // owner gets bounded disposition-repair attempts. While one of those attempts + // is live or queued the state is recessed, not a prominent call to action. + it("reads a bounded repair attempt on deliberate_wait_without_target as in progress", () => { + const action = { ...base, kind: "deliberate_wait_without_target" as const }; + expect( + deriveRecoveryDisplayState(action, { + ownerRun: { agentId: CTO_ID, status: "running" }, + }), + ).toBe("in_progress"); + expect(deriveRecoveryDisplayState(action, { hasQueuedWake: true })).toBe("in_progress"); + }); + + it("keeps deliberate_wait_without_target needed once repair attempts stop", () => { + expect( + deriveRecoveryDisplayState({ ...base, kind: "deliberate_wait_without_target" }, {}), + ).toBe("needed"); + }); + it("prefers the server-projected display state when present", () => { expect( deriveRecoveryDisplayState({ ...base, displayState: "observe_only" } as never, {