fix(ui): label the deliberate-wait recovery kind from the recovery classification change

the recovery classification change added `deliberate_wait_without_target` to
`ISSUE_RECOVERY_ACTION_KINDS` without updating its UI consumer. The two
`Record<IssueRecoveryActionKind, string>` 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) <noreply@anthropic.com>

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
committed 2026-08-15 19:19:38 +00:00
1 parent 843c218e25
commit a88bde1def
3 files changed
+47

No files matched your search

@@ -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<string, string>();
for (const kind of ISSUE_RECOVERY_ACTION_KINDS) {
const node = render(<IssueRecoveryActionCard action={buildAction({ kind })} />);
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(
<IssueRecoveryActionCard
@@ -116,6 +116,7 @@ export interface IssueRecoveryActionCardProps {
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
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<IssueRecoveryActionKind, string> = {
const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
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:
+19
View File
@@ -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, {