diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 45b335f795..03c81933fd 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -643,6 +643,82 @@ describe("renderPaperclipWakePrompt", () => { expect(prompt).toContain("named unblock owner/action"); }); + it("renders resolved checkbox selections in scoped wake prompts", () => { + const payload = { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-1581", + title: "Delete selected files", + status: "in_progress", + }, + interactionKind: "request_checkbox_confirmation", + interactionStatus: "accepted", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("- checkbox prompt: Delete selected files?"); + expect(prompt).toContain("- checkbox selection ids: file-b"); + expect(prompt).toContain("- checkbox selection options: file-b (b.txt) - Generated build output"); + expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, + }); + }); + + it("renders accepted empty checkbox selections explicitly", () => { + const payload = { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-1581", + title: "Delete selected files", + status: "in_progress", + }, + interactionKind: "request_checkbox_confirmation", + interactionStatus: "accepted", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }; + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("- checkbox prompt: Delete selected files?"); + expect(prompt).toContain("- checkbox selection ids: (none)"); + expect(prompt).toContain("- checkbox selection options: (none)"); + expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + }); + }); + it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => { const title = "验证中文任务"; const commentBody = [ diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 8261abdf36..b6ee56659c 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -581,6 +581,16 @@ type PaperclipWakeTreeHoldSummary = { reason: string | null; }; +type PaperclipWakeCheckboxSelection = { + prompt: string | null; + selectedOptionIds: string[]; + selectedOptions: Array<{ + id: string; + label: string; + description: string | null; + }>; +}; + type PaperclipWakePayload = { reason: string | null; issue: PaperclipWakeIssue | null; @@ -597,6 +607,7 @@ type PaperclipWakePayload = { taskWatchdog: PaperclipWakeTaskWatchdogContext | null; interactionKind: string | null; interactionStatus: string | null; + checkboxSelection: PaperclipWakeCheckboxSelection | null; annotationDeltas: PaperclipWakeAnnotationDelta[]; childIssueSummaries: PaperclipWakeChildIssueSummary[]; childIssueSummaryTruncated: boolean; @@ -929,6 +940,42 @@ function normalizePaperclipWakeTreeHoldSummary(value: unknown): PaperclipWakeTre return { holdId, rootIssueId, mode, reason }; } +function normalizePaperclipWakeCheckboxSelection(value: unknown): PaperclipWakeCheckboxSelection | null { + const selection = parseObject(value); + const hasExplicitSelection = + Object.prototype.hasOwnProperty.call(selection, "prompt") || + Object.prototype.hasOwnProperty.call(selection, "selectedOptionIds") || + Object.prototype.hasOwnProperty.call(selection, "selectedOptions"); + const prompt = asString(selection.prompt, "").trim() || null; + const selectedOptionIds = Array.isArray(selection.selectedOptionIds) + ? selection.selectedOptionIds + .map((entry) => asString(entry, "").trim()) + .filter(Boolean) + : []; + const selectedOptions = Array.isArray(selection.selectedOptions) + ? selection.selectedOptions + .map((entry) => { + const option = parseObject(entry); + const id = asString(option.id, "").trim(); + if (!id) return null; + return { + id, + label: asString(option.label, id).trim() || id, + description: asString(option.description, "").trim() || null, + }; + }) + .filter((entry): entry is { id: string; label: string; description: string | null } => Boolean(entry)) + : []; + + if (!hasExplicitSelection && selectedOptionIds.length === 0 && selectedOptions.length === 0 && !prompt) return null; + const optionById = new Map(selectedOptions.map((option) => [option.id, option])); + return { + prompt, + selectedOptionIds, + selectedOptions: selectedOptionIds.map((id) => optionById.get(id) ?? { id, label: id, description: null }), + }; +} + function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null { const principal = parseObject(value); const typeRaw = asString(principal.type, "").trim().toLowerCase(); @@ -1113,7 +1160,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl : []; const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold); - if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !normalizePaperclipWakeIssue(payload.issue)) { + const checkboxSelection = normalizePaperclipWakeCheckboxSelection(payload.checkboxSelection); + if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !checkboxSelection && !normalizePaperclipWakeIssue(payload.issue)) { return null; } @@ -1134,6 +1182,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl taskWatchdog, interactionKind: asString(payload.interactionKind, "").trim() || null, interactionStatus: asString(payload.interactionStatus, "").trim() || null, + checkboxSelection, childIssueSummaries, childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false), commentIds, @@ -1239,6 +1288,21 @@ export function renderPaperclipWakePrompt( if (normalized.issue?.priority) { lines.push(`- issue priority: ${normalized.issue.priority}`); } + if (normalized.checkboxSelection) { + if (normalized.checkboxSelection.prompt) { + lines.push(`- checkbox prompt: ${normalized.checkboxSelection.prompt}`); + } + const selectedOptionIds = normalized.checkboxSelection.selectedOptionIds.join(", ") || "(none)"; + const selectedOptions = normalized.checkboxSelection.selectedOptions + .map((option) => { + const label = option.label && option.label !== option.id ? ` (${option.label})` : ""; + const description = option.description ? ` - ${option.description}` : ""; + return `${option.id}${label}${description}`; + }) + .join(", ") || "(none)"; + lines.push(`- checkbox selection ids: ${selectedOptionIds}`); + lines.push(`- checkbox selection options: ${selectedOptions}`); + } if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) { const hasWakeComments = normalized.comments.length > 0; const acceptedPlanContinuation = diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 567317eabb..4463640d97 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -123,6 +123,11 @@ describe("mergeCoalescedContextSnapshot", () => { interactionKind: "request_confirmation", interactionStatus: "accepted", continuationPolicy: "wake_assignee_on_accept", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, wakeReason: "issue_commented", }, { @@ -137,11 +142,12 @@ describe("mergeCoalescedContextSnapshot", () => { expect(merged.interactionKind).toBeUndefined(); expect(merged.interactionStatus).toBeUndefined(); expect(merged.continuationPolicy).toBeUndefined(); + expect(merged.checkboxSelection).toBeUndefined(); expect(merged.commentId).toBe("comment-1"); expect(merged.wakeCommentId).toBe("comment-1"); }); - it("preserves accepted-plan interaction state for the interaction wake itself", () => { + it("preserves resolved interaction state for the interaction wake itself", () => { const merged = mergeCoalescedContextSnapshot( { issueId: "issue-1", @@ -152,6 +158,11 @@ describe("mergeCoalescedContextSnapshot", () => { interactionKind: "request_confirmation", interactionStatus: "accepted", continuationPolicy: "wake_assignee_on_accept", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, wakeReason: "issue_commented", }, ); @@ -160,6 +171,11 @@ describe("mergeCoalescedContextSnapshot", () => { expect(merged.interactionKind).toBe("request_confirmation"); expect(merged.interactionStatus).toBe("accepted"); expect(merged.continuationPolicy).toBe("wake_assignee_on_accept"); + expect(merged.checkboxSelection).toEqual({ + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }); }); }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index d3574f7903..0c08bcb0d2 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -566,7 +566,7 @@ describe.sequential("issue thread interaction routes", () => { prompt: "Delete selected files?", options: [ { id: "file-a", label: "a.txt" }, - { id: "file-b", label: "b.txt" }, + { id: "file-b", label: "b.txt", description: "Generated build output" }, ], }, result: { @@ -602,6 +602,18 @@ describe.sequential("issue thread interaction routes", () => { interactionId: "interaction-checkbox", interactionKind: "request_checkbox_confirmation", interactionStatus: "accepted", + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, + }), + contextSnapshot: expect.objectContaining({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: ["file-b"], + selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }], + }, }), }), ); @@ -617,6 +629,66 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("preserves accepted empty checkbox selections in assignee wake context", async () => { + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-checkbox-empty", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_checkbox_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-checkbox", + payload: { + version: 1, + prompt: "Delete selected files?", + options: [ + { id: "file-a", label: "a.txt", description: "Temporary export" }, + { id: "file-b", label: "b.txt", description: "Generated build output" }, + ], + }, + result: { + version: 1, + outcome: "accepted", + selectedOptionIds: [], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-checkbox-empty/accept") + .send({ selectedOptionIds: [] }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + payload: expect.objectContaining({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + }), + contextSnapshot: expect.objectContaining({ + checkboxSelection: { + prompt: "Delete selected files?", + selectedOptionIds: [], + selectedOptions: [], + }, + }), + }), + ); + }); + it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => { mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" })); mockInteractionService.acceptInteraction.mockResolvedValueOnce({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4262972f14..cf9f37ae79 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -967,6 +967,7 @@ function queueResolvedInteractionContinuationWakeup(input: { const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason); const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id); const interactionResult = readConfirmationResultForWake(input.interaction.result); + const checkboxSelection = readCheckboxSelectionForWake(input.interaction); const planReviewInteraction = planTarget && input.interaction.kind === "request_confirmation" ? { @@ -990,6 +991,7 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceCommentId: input.interaction.sourceCommentId ?? null, sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), + ...(checkboxSelection ? { checkboxSelection } : {}), mutation: "interaction", }, requestedByActorType: input.actor.actorType, @@ -1003,6 +1005,7 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceCommentId: input.interaction.sourceCommentId ?? null, sourceRunId: input.interaction.sourceRunId ?? null, ...(planReviewInteraction ? { planReviewInteraction } : {}), + ...(checkboxSelection ? { checkboxSelection } : {}), wakeReason: "issue_commented", source: input.source, ...(forceFreshSession ? { forceFreshSession: true } : {}), @@ -1016,6 +1019,41 @@ function queueResolvedInteractionContinuationWakeup(input: { }, "failed to wake assignee on issue interaction resolution")); } +function readCheckboxSelectionForWake(input: { + kind: string; + payload?: unknown; + result?: unknown; +}) { + if (input.kind !== "request_checkbox_confirmation") return null; + const result = readObject(input.result); + if (result.outcome !== "accepted") return null; + const selectedOptionIds = Array.isArray(result.selectedOptionIds) + ? result.selectedOptionIds.filter((value): value is string => typeof value === "string" && value.length > 0) + : []; + const payload = readObject(input.payload); + const options = Array.isArray(payload.options) + ? payload.options + .map((value) => { + const option = readObject(value); + const id = readNonEmptyString(option.id); + if (!id) return null; + return { + id, + label: readNonEmptyString(option.label) ?? id, + description: readNonEmptyString(option.description), + }; + }) + .filter((value): value is { id: string; label: string; description: string | null } => Boolean(value)) + : []; + const optionById = new Map(options.map((option) => [option.id, option])); + + return { + prompt: readNonEmptyString(payload.prompt), + selectedOptionIds, + selectedOptions: selectedOptionIds.map((id) => optionById.get(id) ?? { id, label: id, description: null }), + }; +} + function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 70e5465494..67eb46f7bd 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3545,6 +3545,7 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ "interactionKind", "interactionStatus", "continuationPolicy", + "checkboxSelection", ] as const; function isInteractionResolutionWakePayload(payload: Record | null | undefined) { @@ -3844,6 +3845,7 @@ export async function buildPaperclipWakePayload(input: { const interactionId = readNonEmptyString(input.contextSnapshot.interactionId); const interactionKind = readNonEmptyString(input.contextSnapshot.interactionKind); const interactionStatus = readNonEmptyString(input.contextSnapshot.interactionStatus); + const checkboxSelection = parseObject(input.contextSnapshot.checkboxSelection); const planReviewContext = issueId ? await buildPlanReviewContext({ db: input.db, @@ -3888,6 +3890,7 @@ export async function buildPaperclipWakePayload(input: { : null, interactionKind, interactionStatus, + checkboxSelection: Object.keys(checkboxSelection).length > 0 ? checkboxSelection : null, checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,