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", });