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:
parent
539c9212f4
commit
05735b3d87
|
|
@ -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>"',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue