[codex] Include checkbox selections in continuation wakes (#8893)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue-thread interactions are the subsystem that lets board users
answer structured prompts and resume agent work
> - Checkbox confirmations capture a selected subset of known options,
then wake the assignee through continuation context
> - The wake context previously carried generic interaction metadata,
but not the accepted checkbox option ids or option labels
> - That meant the resumed agent could be woken after a checkbox
confirmation without seeing the board's selected options in the turn
context
> - This pull request carries accepted checkbox selections through the
interaction continuation wake snapshot and renders them into the adapter
wake prompt
> - The benefit is that agents can act on checkbox-confirmation
selections without refetching or guessing the user's choices

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. Searched public issues and
PRs for checkbox confirmation / continuation selection duplicates and
found no matching issue or PR.

### Pre-submission checklist

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I am on the latest released version of Paperclip or can reproduce
on `master`.
- [x] I have confirmed the error originates in Paperclip itself, not in
my agent adapter, API provider, or local configuration.

### What happened?

When a board user accepted a `request_checkbox_confirmation`
interaction, the assignee continuation wake included generic interaction
metadata but did not include the accepted checkbox selections. The
resumed agent turn therefore had no in-prompt access to the selected
option ids or option labels/descriptions.

### Expected behavior

When a `request_checkbox_confirmation` interaction is accepted, the
resumed agent wake should include the checkbox prompt, selected option
ids, and selected option labels/descriptions so the agent can act on the
selected subset directly.

### Steps to reproduce

1. Create an issue-thread `request_checkbox_confirmation` interaction
with multiple options and `continuationPolicy: "wake_assignee"`.
2. Accept the interaction with one or more selected options.
3. Inspect the continuation wake payload/prompt received by the
assignee.
4. Observe that the selected checkbox options are missing from the wake
context before this fix.

### Paperclip version or commit

Reproduced against the pre-fix code path on `master`; this PR head is
`9d17e70bce373e4850117f30c015c973c4b61789`.

### Deployment mode

Local dev (pnpm dev) / built from source.

### Installation method

Built from source (pnpm dev / pnpm build).

### Agent adapter(s) involved

Not adapter-specific (core bug). The Codex/local adapter path exposed
the missing wake context, but the missing field was in core interaction
continuation payload construction.

### Database mode

Embedded PGlite or external Postgres; the bug is not database-mode
specific.

### Access context

Both. Board users resolve the checkbox interaction, and agent bearer-key
wakes consume the continuation context.

### Node.js version

`v22.22.2`

### Operating system

Linux workspace.

### Relevant logs or output

No runtime exception is required to reproduce this. The failure mode is
missing `checkboxSelection` data in the resolved interaction
continuation wake payload.

### Relevant config

Not config-related.

### Additional context

Root cause: accepted checkbox interaction results were not extracted
into the continuation wake context, and adapter wake payload
normalization/rendering had no typed `checkboxSelection` field.

### Privacy checklist

- [x] I have reviewed all pasted output for PII (usernames, file paths,
API keys, tokens, company names) and redacted where necessary.

## What Changed

- Added checkbox selection extraction for accepted
`request_checkbox_confirmation` interactions and stored it in
interaction continuation wake context.
- Included checkbox selection context in heartbeat wake payload
construction.
- Added adapter-utils normalization and wake prompt rendering for
checkbox prompt, selected ids, and selected option details.
- Added regression coverage for route continuation context, heartbeat
payload summaries, and adapter wake prompt rendering.

## Verification

- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-context-summary.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts`
- `git diff --check origin/master...HEAD`
- `rg -n "checkbox|confirmation|interaction|wake|continuation"
ROADMAP.md`
- `gh pr list --state all --search "checkbox continuation selection
repo:paperclipai/paperclip" --json number,title,state,url,headRefName
--limit 20`
- `gh issue list --state all --search "checkbox confirmation options
repo:paperclipai/paperclip" --json number,title,state,url --limit 20`

## Risks

Low risk. The new payload field is additive, only populated for accepted
checkbox confirmations, and existing continuation fields are preserved.
The main compatibility risk is downstream code assuming an exact wake
payload shape; adapter normalization treats the new field as optional.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex coding agent based on GPT-5, with shell/tool execution in
this workspace.

## 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 (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-02 18:39:16 -05:00 committed by GitHub
parent 2c4c110e90
commit 246e1b38bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 272 additions and 3 deletions

View File

@ -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 = [

View File

@ -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 =

View File

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

View File

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

View File

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

View File

@ -3545,6 +3545,7 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
"interactionKind",
"interactionStatus",
"continuationPolicy",
"checkboxSelection",
] as const;
function isInteractionResolutionWakePayload(payload: Record<string, unknown> | 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,