From a88bde1def10df106d522e7e8805d470f8e15dd0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 16:59:35 +0000 Subject: [PATCH] fix(ui): label the deliberate-wait recovery kind from the recovery classification change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the recovery classification change added `deliberate_wait_without_target` to `ISSUE_RECOVERY_ACTION_KINDS` without updating its UI consumer. The two `Record` maps in the recovery card are exhaustive, so the two branches typechecked in isolation but broke the moment they met: IssueRecoveryActionCard.tsx(117,7): error TS2741: Property 'deliberate_wait_without_target' is missing ... Give the kind first-class copy instead of letting it fall through to the generic `missing_disposition` headline. Per the spec this kind means a run stopped to wait on something that does not exist, and the original owner gets bounded disposition-repair attempts — so while one of those attempts is live or queued the display derivation already resolves it to the recessed `in_progress` state, which is exactly this task's thesis. No derivation change was needed; the liveness logic is kind-agnostic. Prevention: the break was invisible to both branches' own test runs, so add a guard that renders every kind in the shared union and asserts each gets distinct, non-empty copy. Verified by mutation — pointing the new kind at the generic headline fails the guard. Verified: tsc clean, 218 UI tests across 9 affected suites, the recovery back end's disposition-repair server suite, and all three token gates clean. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Paperclip --- .../IssueRecoveryActionCard.test.tsx | 25 +++++++++++++++++++ ui/src/components/IssueRecoveryActionCard.tsx | 3 +++ ui/src/lib/recovery-display.test.ts | 19 ++++++++++++++ 3 files changed, 47 insertions(+) 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, {