fix(ui): show continuation actions in confirmation receipts (#12939)

## 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
This commit is contained in:
Dotta 2026-09-06 13:56:18 -05:00 committed by GitHub
parent 539c9212f4
commit 05735b3d87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 158 additions and 6 deletions

View File

@ -109,7 +109,14 @@ describe("runner E2E catalog", () => {
'"reviewInteractionId":"<returned interaction id>"',
);
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":"<returned interaction id>"',
);

View File

@ -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":"<returned interaction id>"}. 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":"<returned interaction id>"}. 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

View File

@ -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",

View File

@ -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", () => {
<TaskChatInteractionCard
item={interactionItem(acceptedConfirmation)}
/>
<TaskChatInteractionCard
item={interactionItem(continuedConfirmation)}
/>
<TaskChatInteractionCard
item={interactionItem(acceptedSelection)}
/>
@ -559,11 +573,14 @@ describe("TaskChatInteractionCard", () => {
const receipts = container.querySelectorAll<HTMLDetailsElement>(
'[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) {

View File

@ -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: {

View File

@ -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";

View File

@ -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<void>;
stopRunLabel?: string;
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestConfirmationInteraction,
reason?: string,
) => Promise<void>;
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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onRejectInteraction?: (
interaction: RequestConfirmationInteraction,
reason?: string,
) => Promise<void>;
};
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 () => {

View File

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