From 05735b3d87a240fefb13233a18efbc93da5b72cd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:56:18 -0500 Subject: [PATCH] fix(ui): show continuation actions in confirmation receipts (#12939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task interactions let an operator approve completion or ask an agent to continue. > - The server stores the continue choice as a rejected completion request so it can resume the same task. > - The task feed ignored the configured action label and showed the generic text `Declined request`. > - The generic text made a successful three-turn continuation look like a failed request. > - This pull request keeps the server state and shows the action that the operator selected. > - The benefit is an accurate task feed for legacy Codex and Runner Codex. ## Linked Issues or Issue Description **What happened?** A rejected confirmation always appeared as `Declined request`. The feed did not use a custom rejection action such as `Continue work`. **Expected behavior** The resolved receipt and success toast must show the selected custom action. Confirmations without a custom action must keep the current fallback text. **Steps to reproduce** 1. Create a completion confirmation with `rejectLabel` set to `Continue work`. 2. Select `Continue work` and enter a continuation note. 3. Open the completed task feed. 4. Observe that the old UI says `Declined request` instead of the selected action. **Paperclip version or commit** `539c9212f4b98e37643f5a8e3b603f1b5845b5d7` **Deployment mode** GitHub Actions Runner E2E with warm Daytona sandboxes. ## What Changed - Show `Selected “Continue work”` when a rejected confirmation has that custom action label. - Use the same action-aware text in the success toast. - Keep `Declined request` as the fallback for confirmations without a custom rejection label. - Make the legacy warm-turn prompt ask if the task is ready to complete. - Require both warm Daytona matrix cells to show two continuation receipts and no generic decline receipt. ## Verification - `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx ui/src/lib/issue-thread-interactions.test.ts ui/src/components/task-chat/TaskChatInteractionCard.test.tsx` - `pnpm test:e2e:runner:unit` - `pnpm test:e2e:runner:typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/ui build` - `pnpm check:token-gates` - Paid `daytona-warm-continuity` campaign: both three-turn cells passed on attempt 1 ([workflow run](https://github.com/paperclipai/paperclip/actions/runs/34052047946)); the downloaded evidence aggregates locally as 2/2. The trusted report job did not publish because default branch `pnpm-lock.yaml` was transiently behind its manifest. - `pnpm typecheck` reached an unrelated `plugin-workspace-diff` dependency type error after the current master manifest and lockfile resolved different versions. The focused UI and runner checks pass. ## Risks - Low risk. The database state and continuation behavior do not change. - A custom rejection label now appears in resolved receipts and success toasts. - The paid warm Daytona suite has a stricter browser assertion. ## Model Used - OpenAI Codex, GPT-5, with reasoning, tool use, and code 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 linked existing issues or described the issue in this PR with the bug template fields - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal task identifier - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated the relevant E2E fixture and assertions - [x] I have considered and documented the 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 --- tests/runner-e2e/catalog.test.ts | 7 ++ tests/runner-e2e/catalog.ts | 2 +- tests/runner-e2e/runner.spec.ts | 9 +++ .../TaskChatInteractionCard.test.tsx | 21 ++++- ui/src/lib/issue-thread-interactions.test.ts | 32 ++++++++ ui/src/lib/issue-thread-interactions.ts | 5 +- ui/src/pages/IssueDetail.test.tsx | 81 +++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 7 +- 8 files changed, 158 insertions(+), 6 deletions(-) diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index dbdbe995e4..3d9abfbe36 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -109,7 +109,14 @@ describe("runner E2E catalog", () => { '"reviewInteractionId":""', ); expect(initialPrompt).toContain('"continuationPolicy":"wake_assignee"'); + expect(initialPrompt).toContain( + '"prompt":"Is this warm continuity task ready to complete after turn 1?"', + ); + expect(initialPrompt).not.toContain("Continue to warm continuity turn 2?"); expect(followups[0]).toContain('"kind":"request_confirmation"'); + expect(followups[0]).toContain( + '"prompt":"Is this warm continuity task ready to complete after turn 2?"', + ); expect(followups[0]).toContain( '"reviewInteractionId":""', ); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index eff09ecf5b..c845d216cb 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -785,7 +785,7 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) { const finalTurn = turn === 3; const legacyCompletion = finalTurn ? `In a legacy runner, make exactly one public-API completion write after verification: PATCH /api/issues/$PAPERCLIP_TASK_ID with {"status":"done","comment":"${marker}"}. Include Authorization and X-Paperclip-Run-Id. Do not POST a separate comment.` - : `In a legacy runner, after verification POST exactly one request_confirmation to /api/issues/$PAPERCLIP_TASK_ID/interactions with {"kind":"request_confirmation","idempotencyKey":"daytona-warm-review-T${turn}-${nonce}","resolverPolicy":"human_only","title":"Warm continuity turn ${turn}","summary":"Review completed warm continuity turn ${turn}.","continuationPolicy":"wake_assignee","payload":{"version":1,"prompt":"Continue to warm continuity turn ${turn + 1}?","acceptLabel":"Approve completion","rejectLabel":"Continue work","rejectRequiresReason":true,"allowDeclineReason":true,"supersedeOnUserComment":false,"target":{"type":"custom","key":"daytona_warm_turn_${turn}","revisionId":"${nonce}-T${turn}","label":"Warm continuity turn ${turn}"}}}. Capture the returned interaction id. Then make exactly one issue PATCH with {"status":"in_review","comment":"${marker}","reviewInteractionId":""}. Include Authorization and X-Paperclip-Run-Id on both writes. If the issue PATCH fails, retry only that PATCH and never create another interaction. Do not POST a separate comment. After both writes succeed, end the response and heartbeat immediately; do not wait or poll because the reviewer action will start the next turn.`; + : `In a legacy runner, after verification POST exactly one request_confirmation to /api/issues/$PAPERCLIP_TASK_ID/interactions with {"kind":"request_confirmation","idempotencyKey":"daytona-warm-review-T${turn}-${nonce}","resolverPolicy":"human_only","title":"Warm continuity turn ${turn}","summary":"Review completed warm continuity turn ${turn}.","continuationPolicy":"wake_assignee","payload":{"version":1,"prompt":"Is this warm continuity task ready to complete after turn ${turn}?","acceptLabel":"Approve completion","rejectLabel":"Continue work","rejectRequiresReason":true,"allowDeclineReason":true,"supersedeOnUserComment":false,"target":{"type":"custom","key":"daytona_warm_turn_${turn}","revisionId":"${nonce}-T${turn}","label":"Warm continuity turn ${turn}"}}}. Capture the returned interaction id. Then make exactly one issue PATCH with {"status":"in_review","comment":"${marker}","reviewInteractionId":""}. Include Authorization and X-Paperclip-Run-Id on both writes. If the issue PATCH fails, retry only that PATCH and never create another interaction. Do not POST a separate comment. After both writes succeed, end the response and heartbeat immediately; do not wait or poll because the reviewer action will start the next turn.`; return [ `This is warm Daytona continuity turn ${turn} of 3. Work only in the current execution workspace.`, turn === 1 diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index e56807db00..bb9256d518 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -2324,6 +2324,15 @@ for (const execution of executions) { exact: true, }), ).toBeVisible({ timeout: 30_000 }); + if (execution.task.flow === "warm_three_turn") { + const continuedReceipts = page + .getByTestId("task-chat-interaction-receipt") + .filter({ hasText: "Selected “Continue work”" }); + await expect(continuedReceipts).toHaveCount(2, { timeout: 30_000 }); + await expect( + page.getByText("Declined request", { exact: true }), + ).toHaveCount(0); + } await captureScreenshot( "final-state", "Final visible task state", diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index a8189740c6..220b733603 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -522,12 +522,23 @@ describe("TaskChatInteractionCard", () => { expect(container.textContent).not.toContain("Resolved by:"); }); - it("collapses accepted confirmations and selections to one borderless row", () => { + it("collapses resolved confirmations and selections to one borderless row", () => { const acceptedConfirmation = createRequestConfirmation({ status: "accepted", resolvedAt: new Date("2026-08-24T13:30:00.000Z"), result: { version: 1, outcome: "accepted" }, }); + const continuedConfirmation = createRequestConfirmation({ + id: "confirmation-continued", + status: "rejected", + resolvedAt: new Date("2026-08-24T13:30:30.000Z"), + payload: { + version: 1, + prompt: "Is this task ready to complete?", + rejectLabel: "Continue work", + }, + result: { version: 1, outcome: "rejected" }, + }); const acceptedSelection = structuredClone( pendingRequestCheckboxConfirmationInteraction, ); @@ -547,6 +558,9 @@ describe("TaskChatInteractionCard", () => { + @@ -559,11 +573,14 @@ describe("TaskChatInteractionCard", () => { const receipts = container.querySelectorAll( '[data-testid="task-chat-interaction-receipt"]', ); - expect(receipts).toHaveLength(2); + expect(receipts).toHaveLength(3); expect(receipts[0]?.querySelector("summary")?.textContent).toBe( "Confirmed request", ); expect(receipts[1]?.querySelector("summary")?.textContent).toBe( + "Selected “Continue work”", + ); + expect(receipts[2]?.querySelector("summary")?.textContent).toBe( "Confirmed with no options selected", ); for (const receipt of receipts) { diff --git a/ui/src/lib/issue-thread-interactions.test.ts b/ui/src/lib/issue-thread-interactions.test.ts index 4eb8cad051..8c0c53a882 100644 --- a/ui/src/lib/issue-thread-interactions.test.ts +++ b/ui/src/lib/issue-thread-interactions.test.ts @@ -313,6 +313,38 @@ describe("issue thread interaction helpers", () => { })).toBe("Selection expired after target changed"); }); + it("uses a confirmation's explicit rejection action in its receipt", () => { + const base = { + id: "interaction-confirmation", + companyId: "company-1", + issueId: "issue-1", + kind: "request_confirmation" as const, + status: "rejected" as const, + continuationPolicy: "wake_assignee" as const, + ...resolverPolicyFields, + createdAt: "2026-04-06T12:00:00.000Z", + updatedAt: "2026-04-06T12:01:00.000Z", + result: { version: 1 as const, outcome: "rejected" as const }, + }; + + expect(buildIssueThreadInteractionSummary({ + ...base, + payload: { + version: 1 as const, + prompt: "Is this task ready to complete?", + rejectLabel: "Continue work", + }, + })).toBe("Selected “Continue work”"); + + expect(buildIssueThreadInteractionSummary({ + ...base, + payload: { + version: 1 as const, + prompt: "Proceed?", + }, + })).toBe("Declined request"); + }); + it("maps selected checkbox option ids back to labels", () => { const labels = getCheckboxConfirmationSelectedLabels({ payload: { diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts index 560d86ce19..3117982b0a 100644 --- a/ui/src/lib/issue-thread-interactions.ts +++ b/ui/src/lib/issue-thread-interactions.ts @@ -223,7 +223,10 @@ export function buildIssueThreadInteractionSummary( if (interaction.kind === "request_confirmation") { if (interaction.status === "accepted") return "Confirmed request"; - if (interaction.status === "rejected") return "Declined request"; + if (interaction.status === "rejected") { + const rejectLabel = interaction.payload.rejectLabel?.trim(); + return rejectLabel ? `Selected “${rejectLabel}”` : "Declined request"; + } if (interaction.status === "expired") { const outcome = interaction.result?.outcome; if (outcome === "superseded_by_comment") return "Confirmation expired after comment"; diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 533a776302..f570fa1276 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -7,6 +7,7 @@ import type { IssueAttachment, IssueComment, IssueQueuedCommentQueue, + RequestConfirmationInteraction, IssueTreeControlPreview, IssueTreeHold, IssueWorkProduct, @@ -69,6 +70,7 @@ const mockIssuesApi = vi.hoisted(() => ({ deleteAttachment: vi.fn(), upsertDocument: vi.fn(), getDocument: vi.fn(), + rejectInteraction: vi.fn(), })); const mockActivityApi = vi.hoisted(() => ({ @@ -380,6 +382,10 @@ vi.mock("../components/TaskChatThread", () => ({ onStopRun?: (runId: string) => Promise; stopRunLabel?: string; onTryAgainNoLiveExecutionPath?: () => Promise | void; + onRejectInteraction?: ( + interaction: RequestConfirmationInteraction, + reason?: string, + ) => Promise; runFinalizationActions?: readonly { id: string; label: string; @@ -4412,6 +4418,81 @@ describe("IssueDetail", () => { }); }); + it("reports the selected continuation action after rejecting completion", async () => { + const pendingInteraction = { + id: "interaction-continue", + companyId: "company-1", + issueId: "issue-1", + kind: "request_confirmation", + title: "Review completion", + summary: null, + status: "pending", + continuationPolicy: "wake_assignee", + resolverPolicy: "human_only", + requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", + resolverPolicyProvenance: "explicit", + effectiveResolverPolicySource: "requested", + legacyResolverPolicyAliases: { + requested: "board_only", + effective: "board_only", + }, + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + createdAt: new Date("2026-09-06T12:00:00.000Z"), + updatedAt: new Date("2026-09-06T12:00:00.000Z"), + resolvedAt: null, + payload: { + version: 1, + prompt: "Is this task ready to complete?", + acceptLabel: "Approve completion", + rejectLabel: "Continue work", + }, + result: null, + } satisfies RequestConfirmationInteraction; + const rejectedInteraction = { + ...pendingInteraction, + status: "rejected", + resolvedAt: new Date("2026-09-06T12:01:00.000Z"), + result: { version: 1, outcome: "rejected", reason: "Run turn 2" }, + } satisfies RequestConfirmationInteraction; + mockIssuesApi.get.mockResolvedValue(createIssue()); + mockIssuesApi.rejectInteraction.mockResolvedValue(rejectedInteraction); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + onRejectInteraction?: ( + interaction: RequestConfirmationInteraction, + reason?: string, + ) => Promise; + }; + expect(props.onRejectInteraction).toBeTypeOf("function"); + + await act(async () => { + await props.onRejectInteraction?.(pendingInteraction, "Run turn 2"); + }); + + expect(mockIssuesApi.rejectInteraction).toHaveBeenCalledWith( + "PAP-1", + pendingInteraction.id, + "Run turn 2", + ); + expect(mockPushToast).toHaveBeenCalledWith({ + title: "Selected “Continue work”", + tone: "success", + }); + }); + it("passes ask work mode to the issue chat thread", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "ask" })); await act(async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 16053ad4d1..00dde7513c 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -271,7 +271,10 @@ import { Textarea } from "@/components/ui/textarea"; import { formatIssueActivityAction } from "@/lib/activity-format"; import { copyTextToClipboard } from "../lib/clipboard"; import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"; -import { buildAnsweredQuestionsDeliveryText } from "../lib/issue-thread-interactions"; +import { + buildAnsweredQuestionsDeliveryText, + buildIssueThreadInteractionSummary, +} from "../lib/issue-thread-interactions"; import { resolveIssueDocumentDeepLink } from "../lib/issue-document-deep-link"; import { buildIssueSiblingNavigation, @@ -4488,7 +4491,7 @@ export function IssueDetail() { pushToast({ title: interaction.kind === "request_confirmation" - ? "Request declined" + ? buildIssueThreadInteractionSummary(interaction) : "Suggestion rejected", tone: "success", });