[codex] Add checkbox confirmation issue interactions (#7649)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent work is coordinated through issues, comments, interactions,
and approval-style handoffs.
> - Existing issue-thread interactions could ask questions, suggest
tasks, and request confirmation, but they did not support a structured
checkbox confirmation payload for choosing one or more options.
> - That gap made board/user confirmations harder to validate
consistently across API callers, plugin helpers, CLI tooling, and the
UI.
> - This pull request adds the shared checkbox confirmation contract,
server handling, client helpers, and issue-thread UI needed to render
and submit structured selections.
> - The benefit is that agents can request bounded multi-select
confirmations in the same audited issue-thread flow as other Paperclip
interactions.

## Linked Issues or Issue Description

- No public GitHub issue found for this exact branch. Internal Paperclip
issue: PAP-10415 / PAP-10441 requested creating this PR for the checkbox
confirmation issue-thread UI component work.
- GitHub duplicate search performed for checkbox confirmation /
issue-thread interaction PRs; no matching open PR was found.
- Related issue search result `#7497` was unrelated company file cleanup
work, so it is not linked as a related issue.

## What Changed

- Added shared types, validators, constants, and tests for
`request_checkbox_confirmation` interactions.
- Extended server issue-thread interaction service and routes for
checkbox confirmation creation, validation, expiration, and response
handling.
- Added CLI, MCP, and plugin SDK helper coverage so external callers can
create the new interaction shape consistently.
- Updated the issue-thread interaction UI to render checkbox
confirmations with min/max bounds, selection summaries, stale-target
states, and accept/decline flows.
- Documented the checkbox confirmation interaction contract in the
Paperclip skill/API reference.

## Verification

- Rebased cleanly on `paperclipai/paperclip` `master` fetched into
`public-gh/master` at `a4fa0eaf5`.
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.
- Ran focused tests with `NODE_ENV=test`:

```sh
NODE_ENV=test pnpm run preflight:workspace-links
NODE_ENV=test pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/lib/issue-thread-interactions.test.ts cli/src/__tests__/issue-subresources.test.ts cli/src/__tests__/project-goal.test.ts packages/mcp-server/src/tools.test.ts packages/plugins/sdk/tests/testing-actions.test.ts
```

Result: 8 test files passed, 78 tests passed.
- CI on latest head `63b9e55` is green.
- Greptile Review passed on latest head; GraphQL review-thread check
shows all Greptile threads resolved.

## Risks

- Medium surface area because the interaction contract touches shared
validators, server routes/services, UI rendering, CLI, MCP, plugin SDK
helpers, and docs.
- No database migrations are included.
- `pnpm-lock.yaml` is intentionally excluded per repository lockfile
policy.
- UI screenshots are not attached because the task explicitly requested
not to add design screenshots or images unless they were part of the
work; component tests cover the new rendering and interaction states.

> 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 repository file access,
shell command execution, git/GitHub CLI tooling, and Paperclip
control-plane API access. Exact hosted model ID/context-window metadata
is not exposed inside this runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [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:
DottaandPaperclip authored and GitHub committed 2026-06-06 08:48:43 -05:00
1 parent a4fa0eaf5e
commit 4d5322c821
31 files changed
+2329 -139

No files matched your search

@@ -163,6 +163,7 @@ describe("issue subresource commands", () => {
]); ]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID]); await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-client-keys", "yes"]); await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-client-keys", "yes"]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-option-ids", "file-a,file-b"]);
await run(["issue", "interaction:reject", ISSUE_ID, INTERACTION_ID, "--reason", "no"]); await run(["issue", "interaction:reject", ISSUE_ID, INTERACTION_ID, "--reason", "no"]);
await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]); await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]);
await run([ await run([
@@ -196,6 +197,7 @@ describe("issue subresource commands", () => {
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`], ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`], ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`], ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/reject`], ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/reject`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`], ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/respond`], ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/respond`],
@@ -215,6 +217,9 @@ describe("issue subresource commands", () => {
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`], ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`], ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
]); ]);
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
selectedOptionIds: ["file-a", "file-b"],
});
}); });
}); });
+1
View File
@@ -24,6 +24,7 @@ describe("project and goal commands", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY; delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL; delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_COMPANY_ID;
}); });
afterEach(() => { afterEach(() => {
+4 -1
View File
@@ -149,6 +149,7 @@ interface IssueRecoveryResolveOptions extends BaseClientOptions {
interface InteractionAcceptOptions extends BaseClientOptions { interface InteractionAcceptOptions extends BaseClientOptions {
selectedClientKeys?: string; selectedClientKeys?: string;
selectedOptionIds?: string;
} }
interface InteractionReasonOptions extends BaseClientOptions { interface InteractionReasonOptions extends BaseClientOptions {
@@ -745,11 +746,13 @@ export function registerIssueCommands(program: Command): void {
.argument("<issueId>", "Issue ID") .argument("<issueId>", "Issue ID")
.argument("<interactionId>", "Interaction ID") .argument("<interactionId>", "Interaction ID")
.option("--selected-client-keys <csv>", "Client keys to accept") .option("--selected-client-keys <csv>", "Client keys to accept")
.option("--selected-option-ids <csv>", "Checkbox option IDs to accept")
.action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => { .action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => {
try { try {
const ctx = resolveCommandContext(opts); const ctx = resolveCommandContext(opts);
const payload = acceptIssueThreadInteractionSchema.parse({ const payload = acceptIssueThreadInteractionSchema.parse({
selectedClientKeys: opts.selectedClientKeys === undefined ? undefined : parseCsv(opts.selectedClientKeys), selectedClientKeys: opts.selectedClientKeys === undefined ? undefined : parseCsv(opts.selectedClientKeys),
selectedOptionIds: opts.selectedOptionIds === undefined ? undefined : parseCsv(opts.selectedOptionIds),
}); });
const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/accept`, payload); const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/accept`, payload);
printOutput(interaction, { json: ctx.json }); printOutput(interaction, { json: ctx.json });
@@ -761,7 +764,7 @@ export function registerIssueCommands(program: Command): void {
for (const [name, action, schema, description] of [ for (const [name, action, schema, description] of [
["interaction:reject", "reject", rejectIssueThreadInteractionSchema, "Reject an issue thread interaction"], ["interaction:reject", "reject", rejectIssueThreadInteractionSchema, "Reject an issue thread interaction"],
["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an ask_user_questions issue thread interaction"], ["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an issue thread interaction"],
] as const) { ] as const) {
addCommonClientOptions( addCommonClientOptions(
issue issue
+56
View File
@@ -292,6 +292,62 @@ describe("paperclip MCP tools", () => {
}); });
}); });
it("creates request_checkbox_confirmation interactions with checkbox payloads", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "interaction-1", kind: "request_checkbox_confirmation" }),
);
vi.stubGlobal("fetch", fetchMock);
const tool = getTool("paperclipRequestCheckboxConfirmation");
await tool.execute({
issueId: "PAP-1135",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
});
it("creates approvals with the expected company-scoped payload", async () => { it("creates approvals with the expected company-scoped payload", async () => {
const fetchMock = vi.fn().mockResolvedValue( const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "approval-1" }), mockJsonResponse({ id: "approval-1" }),
+24
View File
@@ -6,6 +6,7 @@ import {
createApprovalSchema, createApprovalSchema,
createIssueInputSchema, createIssueInputSchema,
issueThreadInteractionContinuationPolicySchema, issueThreadInteractionContinuationPolicySchema,
requestCheckboxConfirmationPayloadSchema,
requestConfirmationPayloadSchema, requestConfirmationPayloadSchema,
suggestTasksPayloadSchema, suggestTasksPayloadSchema,
updateIssueSchema, updateIssueSchema,
@@ -144,6 +145,17 @@ const createRequestConfirmationToolSchema = z.object({
payload: requestConfirmationPayloadSchema, payload: requestConfirmationPayloadSchema,
}); });
const createRequestCheckboxConfirmationToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
});
const approvalDecisionSchema = z.object({ const approvalDecisionSchema = z.object({
approvalId: approvalIdSchema, approvalId: approvalIdSchema,
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]), action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
@@ -516,6 +528,18 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio
}, },
}), }),
), ),
makeTool(
"paperclipRequestCheckboxConfirmation",
"Create a request_checkbox_confirmation interaction on an issue",
createRequestCheckboxConfirmationToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "request_checkbox_confirmation",
...body,
},
}),
),
makeTool( makeTool(
"paperclipUpsertIssueDocument", "paperclipUpsertIssueDocument",
"Create or update an issue document", "Create or update an issue document",
+3
View File
@@ -1680,6 +1680,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
async requestConfirmation(issueId, interaction, companyId, options) { async requestConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>; return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>;
}, },
async requestCheckboxConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_checkbox_confirmation" }, companyId, options) as Promise<any>;
},
documents: { documents: {
async list(issueId, companyId) { async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read"); requireCapability(manifest, capabilitySet, "issue.documents.read");
+9 -1
View File
@@ -27,6 +27,7 @@ import type {
SuggestTasksInteraction, SuggestTasksInteraction,
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
CreateIssueThreadInteraction, CreateIssueThreadInteraction,
PluginIssueOriginKind, PluginIssueOriginKind,
IssueSurfaceVisibility, IssueSurfaceVisibility,
@@ -122,6 +123,7 @@ export type {
SuggestTasksInteraction, SuggestTasksInteraction,
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
CreateIssueThreadInteraction, CreateIssueThreadInteraction,
PluginIssueOriginKind, PluginIssueOriginKind,
IssueSurfaceVisibility, IssueSurfaceVisibility,
@@ -1321,7 +1323,7 @@ export interface PluginIssueSummariesClient {
* - `issues.orchestration.read` for orchestration summaries * - `issues.orchestration.read` for orchestration summaries
* - `issue.comments.read` for `listComments` * - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment` * - `issue.comments.create` for `createComment`
* - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, and `requestConfirmation` * - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, `requestConfirmation`, and `requestCheckboxConfirmation`
* - `issue.documents.read` for `documents.list` and `documents.get` * - `issue.documents.read` for `documents.list` and `documents.get`
* - `issue.documents.write` for `documents.upsert` and `documents.delete` * - `issue.documents.write` for `documents.upsert` and `documents.delete`
*/ */
@@ -1454,6 +1456,12 @@ export interface PluginIssuesClient {
companyId: string, companyId: string,
options?: { authorAgentId?: string }, options?: { authorAgentId?: string },
): Promise<RequestConfirmationInteraction>; ): Promise<RequestConfirmationInteraction>;
requestCheckboxConfirmation(
issueId: string,
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "request_checkbox_confirmation" }>, "kind">,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient; documents: PluginIssueDocumentsClient;
/** Read and write blocker relationships. */ /** Read and write blocker relationships. */
@@ -43,6 +43,7 @@ import { fileURLToPath } from "node:url";
import type { import type {
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
PaperclipPluginManifestV1, PaperclipPluginManifestV1,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
SuggestTasksInteraction, SuggestTasksInteraction,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@@ -913,6 +914,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}) as Promise<RequestConfirmationInteraction>; }) as Promise<RequestConfirmationInteraction>;
}, },
async requestCheckboxConfirmation(
issueId: string,
interaction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction> {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction: {
...interaction,
kind: "request_checkbox_confirmation",
},
authorAgentId: options?.authorAgentId,
}) as Promise<RequestCheckboxConfirmationInteraction>;
},
documents: { documents: {
async list(issueId: string, companyId: string) { async list(issueId: string, companyId: string) {
return callHost("issues.documents.list", { issueId, companyId }); return callHost("issues.documents.list", { issueId, companyId });
@@ -72,3 +72,59 @@ describe("createTestHarness action context", () => {
await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true }); await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true });
}); });
}); });
describe("createTestHarness issue interactions", () => {
it("creates request_checkbox_confirmation interactions through the typed host helper", async () => {
const harness = createTestHarness({
manifest,
capabilities: ["issues.create", "issue.interactions.create"],
});
const issue = await harness.ctx.issues.create({
companyId: "company-1",
title: "Pick files",
});
const interaction = await harness.ctx.issues.requestCheckboxConfirmation(
issue.id,
{
idempotencyKey: "checkbox:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
options: [
{ id: "file-a", label: "File A" },
{ id: "file-b", label: "File B", description: "Secondary draft" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
},
},
"company-1",
{ authorAgentId: "agent-1" },
);
expect(interaction).toMatchObject({
issueId: issue.id,
companyId: "company-1",
kind: "request_checkbox_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
idempotencyKey: "checkbox:files",
title: "Choose files",
createdByAgentId: "agent-1",
payload: {
version: 1,
prompt: "Which files should be included?",
options: [
{ id: "file-a", label: "File A" },
{ id: "file-b", label: "File B", description: "Secondary draft" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
},
});
});
});
+3
View File
@@ -179,9 +179,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
"suggest_tasks", "suggest_tasks",
"ask_user_questions", "ask_user_questions",
"request_confirmation", "request_confirmation",
"request_checkbox_confirmation",
] as const; ] as const;
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
export const ISSUE_THREAD_INTERACTION_STATUSES = [ export const ISSUE_THREAD_INTERACTION_STATUSES = [
"pending", "pending",
"accepted", "accepted",
+8
View File
@@ -179,6 +179,7 @@ export {
type IssueThreadInteractionKind, type IssueThreadInteractionKind,
type IssueThreadInteractionStatus, type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy, type IssueThreadInteractionContinuationPolicy,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
type BuiltInIssueOriginKind, type BuiltInIssueOriginKind,
type PluginIssueOriginKind, type PluginIssueOriginKind,
type IssueOriginKind, type IssueOriginKind,
@@ -531,6 +532,9 @@ export type {
RequestConfirmationTarget, RequestConfirmationTarget,
RequestConfirmationPayload, RequestConfirmationPayload,
RequestConfirmationResult, RequestConfirmationResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild, AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition, AcceptedPlanDecomposition,
@@ -541,6 +545,7 @@ export type {
SuggestTasksInteraction, SuggestTasksInteraction,
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
IssueThreadInteraction, IssueThreadInteraction,
IssueThreadInteractionPayload, IssueThreadInteractionPayload,
IssueThreadInteractionResult, IssueThreadInteractionResult,
@@ -977,6 +982,9 @@ export {
requestConfirmationTargetSchema, requestConfirmationTargetSchema,
requestConfirmationPayloadSchema, requestConfirmationPayloadSchema,
requestConfirmationResultSchema, requestConfirmationResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
createIssueThreadInteractionSchema, createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema,
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createIssueThreadInteractionSchema } from "./validators/issue.js"; import {
acceptIssueThreadInteractionSchema,
createIssueThreadInteractionSchema,
} from "./validators/issue.js";
describe("issue thread interaction schemas", () => { describe("issue thread interaction schemas", () => {
it("parses request_confirmation payloads with default no-wake continuation", () => { it("parses request_confirmation payloads with default no-wake continuation", () => {
@@ -67,29 +70,37 @@ describe("issue thread interaction schemas", () => {
}); });
it("accepts custom targets for request_confirmation interactions", () => { it("accepts custom targets for request_confirmation interactions", () => {
const parsed = createIssueThreadInteractionSchema.parse({ for (const href of [
kind: "request_confirmation", "https://example.com/checklist",
payload: { "http://example.com/checklist",
version: 1, "/PAP/issues/PAP-123#document-plan",
prompt: "Proceed with the external checklist?", "#document-plan",
target: { ]) {
type: "custom", const parsed = createIssueThreadInteractionSchema.parse({
key: "external-checklist", kind: "request_confirmation",
revisionId: "checklist-v1", payload: {
revisionNumber: 1, version: 1,
label: "Checklist v1", prompt: "Proceed with the external checklist?",
href: "https://example.com/checklist", target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
revisionNumber: 1,
label: "Checklist v1",
href,
},
}, },
}, });
});
expect(parsed.kind).toBe("request_confirmation"); expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return; if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({ expect(parsed.payload.target).toMatchObject({
type: "custom", type: "custom",
key: "external-checklist", key: "external-checklist",
label: "Checklist v1", label: "Checklist v1",
}); href,
});
}
}); });
it("rejects unsafe request_confirmation target hrefs", () => { it("rejects unsafe request_confirmation target hrefs", () => {
@@ -107,7 +118,16 @@ describe("issue thread interaction schemas", () => {
}, },
} as const; } as const;
for (const href of ["javascript:alert(1)", "data:text/html,hi", "//evil.example/path"]) { for (const href of [
"javascript:alert(1)",
"data:text/html,hi",
"//evil.example/path",
"file:///tmp/x",
"mailto:user@example.com",
"slack://channel?id=1",
"vscode://file/tmp/x",
"ftp://example.com/file",
]) {
expect(() => createIssueThreadInteractionSchema.parse({ expect(() => createIssueThreadInteractionSchema.parse({
...base, ...base,
payload: { payload: {
@@ -117,7 +137,134 @@ describe("issue thread interaction schemas", () => {
href, href,
}, },
}, },
})).toThrow("href must not use javascript:, data:, or protocol-relative URLs"); })).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL");
} }
}); });
it("parses request_checkbox_confirmation payloads with checkbox defaults", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-2", label: "Old screenshot", description: "Captured during QA." },
],
defaultSelectedOptionIds: ["item-2"],
minSelected: 0,
maxSelected: 2,
acceptLabel: "Archive selected",
rejectRequiresReason: true,
target: {
type: "issue_document",
key: "plan",
revisionId: "33333333-3333-4333-8333-333333333333",
revisionNumber: 2,
},
},
});
expect(parsed).toMatchObject({
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
payload: {
allowDeclineReason: true,
defaultSelectedOptionIds: ["item-2"],
minSelected: 0,
maxSelected: 2,
},
});
});
it("rejects invalid request_checkbox_confirmation option references and bounds", () => {
const base = {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-2", label: "Old screenshot" },
],
},
} as const;
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-1", label: "Duplicate" },
],
},
})).toThrow("Option ids must be unique within one checkbox confirmation");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
defaultSelectedOptionIds: ["missing"],
},
})).toThrow("defaultSelectedOptionIds must reference existing option ids");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
defaultSelectedOptionIds: ["item-1"],
minSelected: 2,
},
})).toThrow("defaultSelectedOptionIds must satisfy minSelected");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
minSelected: 2,
maxSelected: 1,
},
})).toThrow("maxSelected must be greater than or equal to minSelected");
});
it("rejects unsafe request_checkbox_confirmation target hrefs", () => {
const base = {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [{ id: "item-1", label: "Draft report" }],
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
label: "Checklist v1",
},
},
} as const;
for (const href of ["file:///tmp/x", "slack://channel?id=1", "vscode://file/tmp/x"]) {
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
target: {
...base.payload.target,
href,
},
},
})).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL");
}
});
it("accepts empty checkbox selections and rejects duplicate selected option ids", () => {
expect(acceptIssueThreadInteractionSchema.parse({ selectedOptionIds: [] })).toEqual({
selectedOptionIds: [],
});
expect(() => acceptIssueThreadInteractionSchema.parse({
selectedOptionIds: ["item-1", "item-1"],
})).toThrow("selectedOptionIds must be unique");
});
}); });
+4
View File
@@ -288,6 +288,9 @@ export type {
RequestConfirmationTarget, RequestConfirmationTarget,
RequestConfirmationPayload, RequestConfirmationPayload,
RequestConfirmationResult, RequestConfirmationResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild, AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition, AcceptedPlanDecomposition,
@@ -298,6 +301,7 @@ export type {
SuggestTasksInteraction, SuggestTasksInteraction,
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
IssueThreadInteraction, IssueThreadInteraction,
IssueThreadInteractionPayload, IssueThreadInteractionPayload,
IssueThreadInteractionResult, IssueThreadInteractionResult,
+40 -3
View File
@@ -786,6 +786,30 @@ export interface RequestConfirmationPayload {
target?: RequestConfirmationTarget | null; target?: RequestConfirmationTarget | null;
} }
export interface RequestCheckboxConfirmationOption {
id: string;
label: string;
description?: string | null;
}
export interface RequestCheckboxConfirmationPayload {
version: 1;
prompt: string;
detailsMarkdown?: string | null;
options: RequestCheckboxConfirmationOption[];
defaultSelectedOptionIds?: string[];
minSelected?: number;
maxSelected?: number | null;
acceptLabel?: string | null;
rejectLabel?: string | null;
rejectRequiresReason?: boolean;
rejectReasonLabel?: string | null;
allowDeclineReason?: boolean;
declineReasonPlaceholder?: string | null;
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
}
export interface RequestConfirmationResult { export interface RequestConfirmationResult {
version: 1; version: 1;
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target"; outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
@@ -794,6 +818,10 @@ export interface RequestConfirmationResult {
staleTarget?: RequestConfirmationTarget | null; staleTarget?: RequestConfirmationTarget | null;
} }
export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult {
selectedOptionIds?: string[];
}
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string; id: string;
companyId: string; companyId: string;
@@ -829,20 +857,29 @@ export interface RequestConfirmationInteraction extends IssueThreadInteractionBa
result?: RequestConfirmationResult | null; result?: RequestConfirmationResult | null;
} }
export interface RequestCheckboxConfirmationInteraction extends IssueThreadInteractionBase {
kind: "request_checkbox_confirmation";
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}
export type IssueThreadInteraction = export type IssueThreadInteraction =
| SuggestTasksInteraction | SuggestTasksInteraction
| AskUserQuestionsInteraction | AskUserQuestionsInteraction
| RequestConfirmationInteraction; | RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
export type IssueThreadInteractionPayload = export type IssueThreadInteractionPayload =
| SuggestTasksPayload | SuggestTasksPayload
| AskUserQuestionsPayload | AskUserQuestionsPayload
| RequestConfirmationPayload; | RequestConfirmationPayload
| RequestCheckboxConfirmationPayload;
export type IssueThreadInteractionResult = export type IssueThreadInteractionResult =
| SuggestTasksResult | SuggestTasksResult
| AskUserQuestionsResult | AskUserQuestionsResult
| RequestConfirmationResult; | RequestConfirmationResult
| RequestCheckboxConfirmationResult;
export interface IssueAttachment { export interface IssueAttachment {
id: string; id: string;
+3
View File
@@ -261,6 +261,9 @@ export {
requestConfirmationTargetSchema, requestConfirmationTargetSchema,
requestConfirmationPayloadSchema, requestConfirmationPayloadSchema,
requestConfirmationResultSchema, requestConfirmationResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
createIssueThreadInteractionSchema, createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema,
+150 -5
View File
@@ -25,6 +25,7 @@ import {
ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES, ISSUE_THREAD_INTERACTION_STATUSES,
MODEL_PROFILE_KEYS, MODEL_PROFILE_KEYS,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
} from "../constants.js"; } from "../constants.js";
import { multilineTextSchema } from "./text.js"; import { multilineTextSchema } from "./text.js";
import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js"; import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js";
@@ -681,11 +682,10 @@ export const askUserQuestionsResultSchema = z.object({
}); });
const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => { const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => {
const lower = value.toLowerCase(); if (value.startsWith("#")) return true;
return !lower.startsWith("javascript:") if (value.startsWith("/")) return !value.startsWith("//");
&& !lower.startsWith("data:") return /^https?:\/\//i.test(value);
&& !value.startsWith("//"); }, "href must be a root-relative path, same-page fragment, or http(s) URL");
}, "href must not use javascript:, data:, or protocol-relative URLs");
const requestConfirmationTargetBaseSchema = z.object({ const requestConfirmationTargetBaseSchema = z.object({
label: z.string().trim().min(1).max(120).nullable().optional(), label: z.string().trim().min(1).max(120).nullable().optional(),
@@ -727,6 +727,106 @@ export const requestConfirmationPayloadSchema = z.object({
target: requestConfirmationTargetSchema.nullable().optional(), target: requestConfirmationTargetSchema.nullable().optional(),
}); });
export const requestCheckboxConfirmationOptionSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
});
export const requestCheckboxConfirmationPayloadSchema = z.object({
version: z.literal(1),
prompt: z.string().trim().min(1).max(1000),
detailsMarkdown: z.string().max(20000).nullable().optional(),
options: z.array(requestCheckboxConfirmationOptionSchema)
.min(1)
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT),
defaultSelectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional()
.default([]),
minSelected: z.number().int().min(0).optional().default(0),
maxSelected: z.number().int().min(0).nullable().optional(),
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectRequiresReason: z.boolean().optional(),
rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
allowDeclineReason: z.boolean().optional().default(true),
declineReasonPlaceholder: z.string().trim().min(1).max(240).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
}).superRefine((value, ctx) => {
const optionIds = new Set<string>();
for (const [index, option] of value.options.entries()) {
if (optionIds.has(option.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Option ids must be unique within one checkbox confirmation",
path: ["options", index, "id"],
});
}
optionIds.add(option.id);
}
const defaultSelectedOptionIds = new Set<string>();
for (const [index, optionId] of value.defaultSelectedOptionIds.entries()) {
if (defaultSelectedOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must be unique",
path: ["defaultSelectedOptionIds", index],
});
continue;
}
defaultSelectedOptionIds.add(optionId);
if (!optionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must reference existing option ids",
path: ["defaultSelectedOptionIds", index],
});
}
}
const maxSelected = value.maxSelected ?? null;
if (value.minSelected > value.options.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "minSelected cannot exceed the option count",
path: ["minSelected"],
});
}
if (value.defaultSelectedOptionIds.length < value.minSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must satisfy minSelected",
path: ["defaultSelectedOptionIds"],
});
}
if (maxSelected != null) {
if (maxSelected < value.minSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "maxSelected must be greater than or equal to minSelected",
path: ["maxSelected"],
});
}
if (maxSelected > value.options.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "maxSelected cannot exceed the option count",
path: ["maxSelected"],
});
}
if (value.defaultSelectedOptionIds.length > maxSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds cannot exceed maxSelected",
path: ["defaultSelectedOptionIds"],
});
}
}
});
export const requestConfirmationResultSchema = z.object({ export const requestConfirmationResultSchema = z.object({
version: z.literal(1), version: z.literal(1),
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]), outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
@@ -735,6 +835,25 @@ export const requestConfirmationResultSchema = z.object({
staleTarget: requestConfirmationTargetSchema.nullable().optional(), staleTarget: requestConfirmationTargetSchema.nullable().optional(),
}); });
export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({
selectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional(),
}).superRefine((value, ctx) => {
const selectedOptionIds = value.selectedOptionIds ?? [];
const seenOptionIds = new Set<string>();
for (const [index, optionId] of selectedOptionIds.entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
}
seenOptionIds.add(optionId);
}
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({ z.object({
kind: z.literal("suggest_tasks"), kind: z.literal("suggest_tasks"),
@@ -766,12 +885,25 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"), continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
payload: requestConfirmationPayloadSchema, payload: requestConfirmationPayloadSchema,
}), }),
z.object({
kind: z.literal("request_checkbox_confirmation"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
}),
]); ]);
export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>; export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
export const acceptIssueThreadInteractionSchema = z.object({ export const acceptIssueThreadInteractionSchema = z.object({
selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(), selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(),
selectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional(),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
const seenClientKeys = new Set<string>(); const seenClientKeys = new Set<string>();
for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) { for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) {
@@ -785,6 +917,19 @@ export const acceptIssueThreadInteractionSchema = z.object({
} }
seenClientKeys.add(clientKey); seenClientKeys.add(clientKey);
} }
const seenOptionIds = new Set<string>();
for (const [index, optionId] of (value.selectedOptionIds ?? []).entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
continue;
}
seenOptionIds.add(optionId);
}
}); });
export type AcceptIssueThreadInteraction = z.infer<typeof acceptIssueThreadInteractionSchema>; export type AcceptIssueThreadInteraction = z.infer<typeof acceptIssueThreadInteractionSchema>;
@@ -542,6 +542,74 @@ describe.sequential("issue thread interaction routes", () => {
); );
}); });
it("accepts request checkbox confirmations with selected option ids and wakes the assignee", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-checkbox",
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" },
{ id: "file-b", label: "b.txt" },
],
},
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["file-b"],
},
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/accept")
.send({ selectedOptionIds: ["file-b"] });
expect(res.status).toBe(200);
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-checkbox",
{ selectedOptionIds: ["file-b"] },
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-checkbox",
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_accepted",
details: expect.objectContaining({
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
}),
}),
);
});
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => { it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" })); mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({ mockInteractionService.acceptInteraction.mockResolvedValueOnce({
@@ -746,6 +746,164 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
})).rejects.toThrow("A decline reason is required for this confirmation"); })).rejects.toThrow("A decline reason is required for this confirmation");
}); });
it("accepts request_checkbox_confirmation interactions with selected option ids", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation accept");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which files should be deleted?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
{ id: "file-c", label: "c.txt" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 0,
maxSelected: 2,
},
}, {
userId: "local-board",
});
expect(created).toMatchObject({
kind: "request_checkbox_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
supersedeOnUserComment: true,
allowDeclineReason: true,
},
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["file-c", "file-a"],
}, {
userId: "local-board",
});
expect(accepted.createdIssues).toEqual([]);
expect(accepted.interaction).toMatchObject({
kind: "request_checkbox_confirmation",
status: "accepted",
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["file-a", "file-c"],
},
resolvedByUserId: "local-board",
});
});
it("enforces request_checkbox_confirmation selected option references and bounds", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation bounds");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Pick one or two options.",
options: [
{ id: "one", label: "One" },
{ id: "two", label: "Two" },
{ id: "three", label: "Three" },
],
defaultSelectedOptionIds: ["one"],
minSelected: 1,
maxSelected: 2,
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: [],
}, {
userId: "local-board",
})).rejects.toThrow("Select at least 1 checkbox confirmation option(s)");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["missing"],
}, {
userId: "local-board",
})).rejects.toThrow("Unknown checkbox confirmation optionId: missing");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["one", "two", "three"],
}, {
userId: "local-board",
})).rejects.toThrow("Select no more than 2 checkbox confirmation option(s)");
});
it("expires request_checkbox_confirmation interactions when a user comments after creation", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Checkbox confirmation supersede");
const commentId = randomUUID();
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which files should be deleted?",
options: [{ id: "file-a", label: "a.txt" }],
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
kind: "request_checkbox_confirmation",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId,
},
});
});
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => { it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
const companyId = randomUUID(); const companyId = randomUUID();
const goalId = randomUUID(); const goalId = randomUUID();
+4 -2
View File
@@ -6033,7 +6033,9 @@ export function issueRoutes(
}); });
} }
const acceptedPlanTarget = readAcceptedPlanConfirmationTarget(interaction.payload); const acceptedPlanTarget = interaction.kind === "request_confirmation"
? readAcceptedPlanConfirmationTarget(interaction.payload)
: null;
const acceptedPlanConfirmation = const acceptedPlanConfirmation =
interaction.kind === "request_confirmation" && interaction.kind === "request_confirmation" &&
interaction.status === "accepted" && interaction.status === "accepted" &&
@@ -6091,7 +6093,7 @@ export function issueRoutes(
rejectionReason: rejectionReason:
interaction.kind === "suggest_tasks" interaction.kind === "suggest_tasks"
? (interaction.result?.rejectionReason ?? null) ? (interaction.result?.rejectionReason ?? null)
: interaction.kind === "request_confirmation" : interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation"
? (interaction.result?.reason ?? null) ? (interaction.result?.reason ?? null)
: null, : null,
}, },
+113 -21
View File
@@ -16,6 +16,7 @@ import type {
CancelIssueThreadInteraction, CancelIssueThreadInteraction,
CreateIssueThreadInteraction, CreateIssueThreadInteraction,
IssueThreadInteraction, IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
RequestConfirmationTarget, RequestConfirmationTarget,
RejectIssueThreadInteraction, RejectIssueThreadInteraction,
@@ -30,6 +31,8 @@ import {
cancelIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema,
createIssueThreadInteractionSchema, createIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
requestConfirmationPayloadSchema, requestConfirmationPayloadSchema,
requestConfirmationResultSchema, requestConfirmationResultSchema,
suggestTasksPayloadSchema, suggestTasksPayloadSchema,
@@ -70,6 +73,19 @@ type IssueResolutionContext = {
assigneeUserId: string | null; assigneeUserId: string | null;
}; };
const REQUEST_CONFIRMATION_INTERACTION_KINDS = [
"request_confirmation",
"request_checkbox_confirmation",
] as const;
type RequestConfirmationLikeKind = (typeof REQUEST_CONFIRMATION_INTERACTION_KINDS)[number];
type RequestConfirmationLikeInteraction =
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
}
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false; if (typeof error !== "object" || error === null) return false;
const err = error as { code?: string; constraint?: string; constraint_name?: string }; const err = error as { code?: string; constraint?: string; constraint_name?: string };
@@ -128,6 +144,13 @@ function hydrateInteraction(
payload: requestConfirmationPayloadSchema.parse(row.payload), payload: requestConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestConfirmationResultSchema.parse(row.result) : null, result: row.result ? requestConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestConfirmationInteraction; } satisfies RequestConfirmationInteraction;
case "request_checkbox_confirmation":
return {
...base,
kind: "request_checkbox_confirmation",
payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestCheckboxConfirmationInteraction;
default: default:
throw unprocessable(`Unknown interaction kind: ${row.kind}`); throw unprocessable(`Unknown interaction kind: ${row.kind}`);
} }
@@ -149,7 +172,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
current: IssueThreadInteractionRow; current: IssueThreadInteractionRow;
actor: InteractionActor; actor: InteractionActor;
}) { }) {
if (args.current.kind !== "request_confirmation") return false; if (!isRequestConfirmationLikeKind(args.current.kind)) return false;
if (!args.current.createdByAgentId) return false; if (!args.current.createdByAgentId) return false;
if (!args.actor.userId) return false; if (!args.actor.userId) return false;
if (!args.issue.assigneeUserId) return false; if (!args.issue.assigneeUserId) return false;
@@ -158,19 +181,31 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
return true; return true;
} }
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) { function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) {
return interaction.payload.supersedeOnUserComment === true; return interaction.payload.supersedeOnUserComment === true;
} }
function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction { function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction {
if (input.kind !== "request_confirmation") return input; switch (input.kind) {
return { case "request_confirmation":
...input, return {
payload: { ...input,
...input.payload, payload: {
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, ...input.payload,
}, supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
}; },
};
case "request_checkbox_confirmation":
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
default:
return input;
}
} }
function isCommentAtOrAfterInteraction(args: { function isCommentAtOrAfterInteraction(args: {
@@ -255,6 +290,36 @@ function resolveSelectedSuggestedTasks(args: {
}; };
} }
function resolveSelectedCheckboxConfirmationOptions(args: {
interaction: RequestCheckboxConfirmationInteraction;
selectedOptionIds?: AcceptIssueThreadInteraction["selectedOptionIds"];
}) {
const optionIds = new Set(args.interaction.payload.options.map((option) => option.id));
const selectedOptionIds = args.selectedOptionIds ?? args.interaction.payload.defaultSelectedOptionIds ?? [];
const selectedOptionIdSet = new Set<string>();
for (const optionId of selectedOptionIds) {
if (!optionIds.has(optionId)) {
throw unprocessable(`Unknown checkbox confirmation optionId: ${optionId}`);
}
selectedOptionIdSet.add(optionId);
}
const selectedCount = selectedOptionIdSet.size;
const minSelected = args.interaction.payload.minSelected ?? 0;
const maxSelected = args.interaction.payload.maxSelected ?? null;
if (selectedCount < minSelected) {
throw unprocessable(`Select at least ${minSelected} checkbox confirmation option(s)`);
}
if (maxSelected != null && selectedCount > maxSelected) {
throw unprocessable(`Select no more than ${maxSelected} checkbox confirmation option(s)`);
}
return args.interaction.payload.options
.filter((option) => selectedOptionIdSet.has(option.id))
.map((option) => option.id);
}
function normalizeQuestionAnswers(args: { function normalizeQuestionAnswers(args: {
questions: AskUserQuestionsInteraction["payload"]["questions"]; questions: AskUserQuestionsInteraction["payload"]["questions"];
answers: RespondIssueThreadInteraction["answers"]; answers: RespondIssueThreadInteraction["answers"];
@@ -401,8 +466,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
row: IssueThreadInteractionRow; row: IssueThreadInteractionRow;
actor: InteractionActor; actor: InteractionActor;
}): Promise<IssueThreadInteraction | null> { }): Promise<IssueThreadInteraction | null> {
if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null; if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null;
const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction; const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target ?? null; const target = interaction.payload.target ?? null;
if (!target) return null; if (!target) return null;
if (target.type !== "issue_document") return null; if (target.type !== "issue_document") return null;
@@ -522,6 +587,7 @@ export function issueThreadInteractionService(db: Db) {
async function acceptRequestConfirmation(args: { async function acceptRequestConfirmation(args: {
issue: { id: string; companyId: string }; issue: { id: string; companyId: string };
current: IssueThreadInteractionRow; current: IssueThreadInteractionRow;
input: AcceptIssueThreadInteraction;
actor: InteractionActor; actor: InteractionActor;
}): Promise<{ }): Promise<{
interaction: IssueThreadInteraction; interaction: IssueThreadInteraction;
@@ -535,6 +601,15 @@ export function issueThreadInteractionService(db: Db) {
return { interaction: expired, continuationIssue: null }; return { interaction: expired, continuationIssue: null };
} }
const interaction = hydrateInteraction(args.current);
const selectedOptionIds =
interaction.kind === "request_checkbox_confirmation"
? resolveSelectedCheckboxConfirmationOptions({
interaction,
selectedOptionIds: args.input.selectedOptionIds,
})
: undefined;
const now = new Date(); const now = new Date();
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [updated] = await tx const [updated] = await tx
@@ -544,6 +619,7 @@ export function issueThreadInteractionService(db: Db) {
result: { result: {
version: 1, version: 1,
outcome: "accepted", outcome: "accepted",
...(selectedOptionIds ? { selectedOptionIds } : {}),
}, },
resolvedByAgentId: args.actor.agentId ?? null, resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null, resolvedByUserId: args.actor.userId ?? null,
@@ -624,7 +700,7 @@ export function issueThreadInteractionService(db: Db) {
return expired; return expired;
} }
const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction; const interaction = hydrateInteraction(args.current) as RequestConfirmationLikeInteraction;
const reason = args.input.reason?.trim() ?? ""; const reason = args.input.reason?.trim() ?? "";
if (interaction.payload.rejectRequiresReason === true && reason.length === 0) { if (interaction.payload.rejectRequiresReason === true && reason.length === 0) {
throw unprocessable("A decline reason is required for this confirmation"); throw unprocessable("A decline reason is required for this confirmation");
@@ -729,7 +805,7 @@ export function issueThreadInteractionService(db: Db) {
} }
} }
if (data.kind === "request_confirmation") { if (data.kind === "request_confirmation" || data.kind === "request_checkbox_confirmation") {
await assertRequestConfirmationTargetIsCurrent(db, { await assertRequestConfirmationTargetIsCurrent(db, {
companyId: issue.companyId, companyId: issue.companyId,
issueId: issue.id, issueId: issue.id,
@@ -798,6 +874,21 @@ export function issueThreadInteractionService(db: Db) {
const accepted = await acceptRequestConfirmation({ const accepted = await acceptRequestConfirmation({
issue, issue,
current, current,
input: data,
actor,
});
return {
interaction: accepted.interaction,
continuationIssue: accepted.continuationIssue,
createdIssues: [],
};
}
case "request_checkbox_confirmation": {
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
const accepted = await acceptRequestConfirmation({
issue,
current,
input: data,
actor, actor,
}); });
return { return {
@@ -970,6 +1061,7 @@ export function issueThreadInteractionService(db: Db) {
case "suggest_tasks": case "suggest_tasks":
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current); return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current);
case "request_confirmation": case "request_confirmation":
case "request_checkbox_confirmation":
return rejectRequestConfirmation({ return rejectRequestConfirmation({
issue, issue,
current, current,
@@ -1038,12 +1130,12 @@ export function issueThreadInteractionService(db: Db) {
.where(and( .where(and(
eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id), eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"), inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.status, "pending"),
)); ));
const superseded = rows.filter((row) => { const superseded = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
return ( return (
shouldSupersedeRequestConfirmationOnUserComment(interaction) shouldSupersedeRequestConfirmationOnUserComment(interaction)
&& isCommentAtOrAfterInteraction({ && isCommentAtOrAfterInteraction({
@@ -1096,7 +1188,7 @@ export function issueThreadInteractionService(db: Db) {
.where(and( .where(and(
eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id), eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"), inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.status, "pending"),
)), )),
db db
@@ -1122,7 +1214,7 @@ export function issueThreadInteractionService(db: Db) {
} }
>(); >();
for (const row of rows) { for (const row of rows) {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue; if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({ const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
@@ -1182,12 +1274,12 @@ export function issueThreadInteractionService(db: Db) {
.where(and( .where(and(
eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id), eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"), inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.status, "pending"),
)); ));
const staleRows = rows.filter((row) => { const staleRows = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target; const target = interaction.payload.target;
if (!target || target.type !== "issue_document") return false; if (!target || target.type !== "issue_document") return false;
const targetIssueId = target.issueId ?? issue.id; const targetIssueId = target.issueId ?? issue.id;
@@ -1206,7 +1298,7 @@ export function issueThreadInteractionService(db: Db) {
const now = new Date(); const now = new Date();
const expired: IssueThreadInteraction[] = []; const expired: IssueThreadInteraction[] = [];
for (const row of staleRows) { for (const row of staleRows) {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target ?? null; const target = interaction.payload.target ?? null;
const currentTarget = buildIssueDocumentTargetFromDocument({ const currentTarget = buildIssueDocumentTargetFromDocument({
issueId: issue.id, issueId: issue.id,
+61
View File
@@ -190,6 +190,67 @@ POST /api/companies/{companyId}/approvals
`issueIds` links the approval into the issue thread. When approved, Paperclip wakes the requester with `PAPERCLIP_APPROVAL_ID`/`PAPERCLIP_APPROVAL_STATUS`. Keep the payload concise and decision-ready. `issueIds` links the approval into the issue thread. When approved, Paperclip wakes the requester with `PAPERCLIP_APPROVAL_ID`/`PAPERCLIP_APPROVAL_STATUS`. Keep the payload concise and decision-ready.
## Issue-Thread Interactions
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
Four kinds are supported. Pick the smallest kind that fits the decision shape:
| Kind | When to use | When **not** to use |
| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. |
| `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
| `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. |
Key shared semantics:
- **Continuation policy.** `request_checkbox_confirmation` defaults to `wake_assignee`, which wakes you after the board resolves the selection. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
- **Target binding and staleness.** `request_confirmation` and `request_checkbox_confirmation` both accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction.
- **Supersede on user comment.** Both confirmation kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required.
- **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards.
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path.
Create a `request_checkbox_confirmation` (board selects any subset, then confirms):
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{ "id": "draft-report-march", "label": "Old draft report", "description": "QA test pass, March." },
{ "id": "tmp-export-2025", "label": "tmp/export-2025.csv" }
],
"defaultSelectedOptionIds": ["draft-report-march"],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
When the board accepts, your wake delivers `result.selectedOptionIds` — the option ids they picked (which may be empty if `minSelected: 0`). Rejection delivers `result.reason` and a `commentId`.
For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**.
## Niche Workflow Pointers ## Niche Workflow Pointers
Load `references/workflows.md` when the task matches one of these: Load `references/workflows.md` when the task matches one of these:
+114 -2
View File
@@ -686,6 +686,118 @@ Rules:
- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide. - A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide.
- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks. - For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks.
### Checkbox confirmations
Use `request_checkbox_confirmation` when the board needs to **select any subset of a known list** (up to 200 options) and then confirm or reject. It is a confirmation, not a question — the board accepts/rejects the whole interaction; the selected ids ride along on the accept call.
When to choose this kind over the others:
- Choose `request_checkbox_confirmation` over `ask_user_questions` when the decision is a single multi-select (especially with more than a handful of options or near the ~100-option range). `ask_user_questions` is for short structured forms, not long lists.
- Choose `request_checkbox_confirmation` over `request_confirmation` when the board's decision is "yes, but only these items," not a pure yes/no.
- Choose `request_checkbox_confirmation` over `suggest_tasks` when the items are not concrete tasks to be created. `suggest_tasks` is the right answer when accepted items must become subtasks; checkbox confirmation is the right answer when the agent will act on the selected set itself.
Create a checkbox confirmation:
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{ "id": "draft-report-march", "label": "Old draft report", "description": "QA test pass, March." },
{ "id": "tmp-export-2025", "label": "tmp/export-2025.csv" }
],
"defaultSelectedOptionIds": ["draft-report-march"],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"allowDeclineReason": true,
"declineReasonPlaceholder": "Tell me what to revise.",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
Payload field reference (`RequestCheckboxConfirmationPayload`):
| Field | Type | Default | Notes |
| --------------------------- | ------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | `1` | required | Versioned for forward compatibility. |
| `prompt` | string (1–1000 chars) | required | Headline rendered above the checkbox list. |
| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. |
| `options` | `[{ id, label, description? }]` | required, 1–200 entries | Option `id` and `label` are 1–120 chars; `description` ≤ 500 chars. Option ids must be unique within the payload. |
| `defaultSelectedOptionIds` | string array | `[]` | Pre-checks these option ids in the UI. Each id must reference an option in `options`. Length must not exceed `maxSelected` when set. |
| `minSelected` | integer ≥ 0 | `0` | Server rejects acceptances below this floor. Cannot exceed `options.length`. |
| `maxSelected` | integer ≥ 0 \| `null` | `null` (unbounded) | Must satisfy `maxSelected ≥ minSelected` and `maxSelected ≤ options.length` when set. |
| `acceptLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for accept. |
| `rejectLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for reject/request-changes. |
| `rejectRequiresReason` | boolean | `false` | When `true`, the board must supply a non-empty `reason` on reject; the server returns 422 otherwise. |
| `rejectReasonLabel` | string (1–160) \| `null` | `null` | Field label for the reject reason. |
| `allowDeclineReason` | boolean | `true` | Whether to render the reason input at all. |
| `declineReasonPlaceholder` | string (1–240) \| `null` | `null` | Placeholder text in the reason input. |
| `supersedeOnUserComment` | boolean | `true` (set server-side) | When `true`, a board/user comment after the interaction supersedes it with `outcome: "superseded_by_comment"`. |
| `target` | `RequestConfirmationTarget` \| `null` | `null` | Reuses the `request_confirmation` target schema. Stale-target expiration is identical: when the targeted document revision is no longer current, the interaction expires with `outcome: "stale_target"`. |
Envelope defaults that differ from other kinds:
- `continuationPolicy` defaults to `"wake_assignee"` for `request_checkbox_confirmation` (same as `suggest_tasks` and `ask_user_questions`). Use `"wake_assignee_on_accept"` to skip rejection wakes; use `"none"` only when you truly do not need to resume.
Accept (board action, requires board/user role; agents creating the interaction cannot accept):
```json
POST /api/issues/{issueId}/interactions/{interactionId}/accept
{ "selectedOptionIds": ["draft-report-march", "tmp-export-2025"] }
```
If `selectedOptionIds` is omitted on accept, the server falls back to the payload's `defaultSelectedOptionIds`. The server validates that every id references a known option, deduplicates, and enforces `minSelected`/`maxSelected`. Unknown ids return 422.
Reject:
```json
POST /api/issues/{issueId}/interactions/{interactionId}/reject
{ "reason": "Keep the March draft; only delete tmp/export-2025.csv." }
```
`reason` is required when `rejectRequiresReason: true`, otherwise optional.
Resolved result (`RequestCheckboxConfirmationResult`):
```json
{
"version": 1,
"outcome": "accepted",
"selectedOptionIds": ["draft-report-march", "tmp-export-2025"]
}
```
Other outcomes match `request_confirmation`:
- `rejected` — `{ outcome: "rejected", reason, commentId }`. `selectedOptionIds` is absent.
- `superseded_by_comment` — `{ outcome: "superseded_by_comment", commentId }`. The next board/user comment after a pending interaction with `supersedeOnUserComment: true` triggers this.
- `stale_target` — `{ outcome: "stale_target", staleTarget }`. Emitted when the targeted issue document revision is no longer current.
Best practice:
- Use a deterministic idempotency key like `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries (e.g. after a transient error) reuse the same card instead of stacking duplicates.
- After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`.
- When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id.
### Checking approval status ### Checking approval status
``` ```
@@ -792,8 +904,8 @@ Terminal states: `done`, `cancelled`
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | | GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | | POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions | | GET | `/api/issues/:issueId/interactions` | List issue-thread interactions |
| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`) | | POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation | | POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation | | POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation |
| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions | | POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions |
| GET | `/api/issues/:issueId/documents` | List issue documents | | GET | `/api/issues/:issueId/documents` | List issue documents |
+1 -1
View File
@@ -213,7 +213,7 @@ export const issuesApi = {
acceptInteraction: ( acceptInteraction: (
id: string, id: string,
interactionId: string, interactionId: string,
data?: { selectedClientKeys?: string[] }, data?: { selectedClientKeys?: string[]; selectedOptionIds?: string[] },
) => ) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
rejectInteraction: (id: string, interactionId: string, reason?: string) => rejectInteraction: (id: string, interactionId: string, reason?: string) =>
+19 -4
View File
@@ -59,6 +59,7 @@ import type {
AskUserQuestionsAnswer, AskUserQuestionsAnswer,
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
IssueThreadInteraction, IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
SuggestTasksInteraction, SuggestTasksInteraction,
} from "../lib/issue-thread-interactions"; } from "../lib/issue-thread-interactions";
@@ -161,11 +162,18 @@ interface IssueChatMessageContext {
onDeleteComment?: (commentId: string) => Promise<void> | void; onDeleteComment?: (commentId: string) => Promise<void> | void;
onImageClick?: (src: string) => void; onImageClick?: (src: string) => void;
onAcceptInteraction?: ( onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction, interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[], selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void; ) => Promise<void> | void;
onRejectInteraction?: ( onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction, interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string, reason?: string,
) => Promise<void> | void; ) => Promise<void> | void;
onSubmitInteractionAnswers?: ( onSubmitInteractionAnswers?: (
@@ -361,11 +369,18 @@ interface IssueChatThreadProps {
stoppingRunId?: string | null; stoppingRunId?: string | null;
onImageClick?: (src: string) => void; onImageClick?: (src: string) => void;
onAcceptInteraction?: ( onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction, interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[], selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void; ) => Promise<void> | void;
onRejectInteraction?: ( onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction, interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string, reason?: string,
) => Promise<void> | void; ) => Promise<void> | void;
onSubmitInteractionAnswers?: ( onSubmitInteractionAnswers?: (
@@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { ThemeProvider } from "../context/ThemeContext"; import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip"; import { TooltipProvider } from "./ui/tooltip";
import { import {
acceptedManyRequestCheckboxConfirmationInteraction,
boundedRequestCheckboxConfirmationInteraction,
pendingAskUserQuestionsInteraction, pendingAskUserQuestionsInteraction,
commentExpiredRequestConfirmationInteraction, commentExpiredRequestConfirmationInteraction,
disabledDeclineReasonRequestConfirmationInteraction, disabledDeclineReasonRequestConfirmationInteraction,
failedRequestConfirmationInteraction, failedRequestConfirmationInteraction,
manyOptionsRequestCheckboxConfirmationInteraction,
pendingRequestCheckboxConfirmationInteraction,
pendingRequestConfirmationInteraction, pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction, pendingSuggestedTasksInteraction,
staleTargetRequestCheckboxConfirmationInteraction,
staleTargetRequestConfirmationInteraction, staleTargetRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction, rejectedSuggestedTasksInteraction,
} from "../fixtures/issueThreadInteractionFixtures"; } from "../fixtures/issueThreadInteractionFixtures";
@@ -338,4 +343,143 @@ describe("IssueThreadInteractionCard", () => {
"This request could not be resolved. Try again or create a new request.", "This request could not be resolved. Try again or create a new request.",
); );
}); });
it("exposes pending checkbox options with select-all and clear controls", () => {
const host = renderCard({
interaction: pendingRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
expect(checkboxes).toHaveLength(
pendingRequestCheckboxConfirmationInteraction.payload.options.length,
);
expect(checkboxes[0]?.getAttribute("aria-checked")).toBe("false");
expect(host.textContent).toContain("0 of 4 options selected");
const selectAll = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent === "Select all",
);
act(() => {
selectAll?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("All 4 options selected");
const clear = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent === "Clear selection",
);
act(() => {
clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("0 of 4 options selected");
});
it("submits selected option ids on accept", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestCheckboxConfirmationInteraction,
onAcceptInteraction,
});
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
await act(async () => {
(checkboxes[0] as HTMLButtonElement).click();
(checkboxes[2] as HTMLButtonElement).click();
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Delete selected"),
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_checkbox_confirmation" }),
undefined,
["draft-march-report", "draft-scratch-notes"],
);
});
it("blocks accept until the minimum selection is met", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: {
...boundedRequestCheckboxConfirmationInteraction,
payload: {
...boundedRequestCheckboxConfirmationInteraction.payload,
defaultSelectedOptionIds: [],
},
},
onAcceptInteraction,
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Confirm regions"),
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).not.toHaveBeenCalled();
expect(host.textContent).toContain("Select at least 2 options.");
});
it("disables remaining checkboxes once the max selection is reached", () => {
const host = renderCard({
interaction: boundedRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
// Defaults select us-west + us-east; bumping to the 3-item max should lock the rest.
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[];
const unchecked = checkboxes.filter((box) => box.getAttribute("aria-checked") === "false");
act(() => {
unchecked[0]?.click();
});
const stillUnchecked = ([...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[])
.filter((box) => box.getAttribute("aria-checked") === "false");
expect(stillUnchecked.length).toBeGreaterThan(0);
expect(stillUnchecked.every((box) => box.hasAttribute("disabled"))).toBe(true);
const selectAllButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Select all"),
);
expect(selectAllButton?.hasAttribute("disabled")).toBe(true);
});
it("summarizes large accepted selections by count and bounds the chips", () => {
const host = renderCard({
interaction: acceptedManyRequestCheckboxConfirmationInteraction,
});
expect(host.textContent).toContain("Confirmed 42 of 100 options");
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
// 42 selected, but only the first 8 labels render inline, then a "+N more" chip.
expect(host.textContent).toContain("+34 more");
});
it("stays compact and scrollable with around 100 options", () => {
const host = renderCard({
interaction: manyOptionsRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(100);
const scrollRegion = host.querySelector('[aria-label="Selectable options"]');
expect(scrollRegion?.className).toContain("max-h-80");
expect(scrollRegion?.className).toContain("overflow-y-auto");
});
it("renders stale-target expiry for checkbox confirmations", () => {
const host = renderCard({
interaction: staleTargetRequestCheckboxConfirmationInteraction,
});
expect(host.textContent).toContain("Expired by target change");
expect(host.textContent).toContain("Plan v3");
expect(host.textContent).toContain("Plan v4");
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
});
}); });
+532 -71
View File
@@ -7,11 +7,15 @@ import {
buildSuggestedTaskTree, buildSuggestedTaskTree,
collectSuggestedTaskClientKeys, collectSuggestedTaskClientKeys,
countSuggestedTaskNodes, countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels, getQuestionAnswerLabels,
type AskUserQuestionsAnswer, type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction, type AskUserQuestionsInteraction,
type IssueThreadInteraction, type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction, type RequestConfirmationInteraction,
type RequestConfirmationResult,
type RequestConfirmationTarget, type RequestConfirmationTarget,
type SuggestTasksInteraction, type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask, type SuggestTasksResultCreatedTask,
@@ -34,11 +38,18 @@ interface IssueThreadInteractionCardProps {
currentUserId?: string | null; currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null; userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: ( onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction, interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[], selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void; ) => Promise<void> | void;
onRejectInteraction?: ( onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction, interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string, reason?: string,
) => Promise<void> | void; ) => Promise<void> | void;
onSubmitInteractionAnswers?: ( onSubmitInteractionAnswers?: (
@@ -96,6 +107,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
return "Ask user questions"; return "Ask user questions";
case "request_confirmation": case "request_confirmation":
return "Confirmation"; return "Confirmation";
case "request_checkbox_confirmation":
return "Checkbox confirmation";
default: default:
return kind; return kind;
} }
@@ -971,33 +984,18 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) {
return `${target.key}${revision}`; return `${target.key}${revision}`;
} }
function requestConfirmationTargetHref({
interaction,
target,
}: {
interaction: RequestConfirmationInteraction;
target: RequestConfirmationTarget;
}) {
if (target.href) return target.href;
if (target.type === "issue_document") {
const issueId = target.issueId ?? interaction.issueId;
return `/issues/${issueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
function RequestConfirmationTargetChip({ function RequestConfirmationTargetChip({
interaction, issueId,
target, target,
tone = "default", tone = "default",
}: { }: {
interaction: RequestConfirmationInteraction; issueId: string;
target: RequestConfirmationTarget | null | undefined; target: RequestConfirmationTarget | null | undefined;
tone?: "default" | "subtle"; tone?: "default" | "subtle";
}) { }) {
if (!target) return null; if (!target) return null;
const href = requestConfirmationTargetHref({ interaction, target }); const href = getRequestConfirmationTargetHref({ issueId, target });
const className = cn( const className = cn(
"inline-flex max-w-full items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]", "inline-flex max-w-full items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]",
tone === "default" tone === "default"
@@ -1027,81 +1025,121 @@ function RequestConfirmationTargetChip({
); );
} }
function ConfirmationDeclinedBlock({
issueId,
result,
target,
}: {
issueId: string;
result: RequestConfirmationResult | null | undefined;
target: RequestConfirmationTarget | null;
}) {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Declined</span>
<RequestConfirmationTargetChip issueId={issueId} target={target} />
</div>
{result?.reason ? (
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
{result.reason}
</blockquote>
) : null}
</div>
);
}
function ConfirmationExpiredBlock({
issueId,
result,
target,
}: {
issueId: string;
result: RequestConfirmationResult | null | undefined;
target: RequestConfirmationTarget | null;
}) {
const outcome = result?.outcome;
const staleTarget = result?.staleTarget ?? null;
const expiredByComment = outcome === "superseded_by_comment";
const expiredByTargetChange = outcome === "stale_target";
return (
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
{expiredByComment ? "Expired by comment" : "Expired by target change"}
</div>
<p className="leading-6">
{expiredByComment
? "A board comment superseded this request before it was resolved."
: "The requested target changed before this request was resolved."}
</p>
{expiredByComment && result?.commentId ? (
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
<a href={`#comment-${result.commentId}`}>Jump to comment</a>
</Button>
) : null}
{expiredByTargetChange ? (
<div className="flex flex-wrap items-center gap-2">
<RequestConfirmationTargetChip
issueId={issueId}
target={staleTarget}
tone="subtle"
/>
{staleTarget && target ? (
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
) : null}
<RequestConfirmationTargetChip issueId={issueId} target={target} />
</div>
) : null}
</div>
);
}
function ConfirmationFailedBlock() {
return (
<p className="text-sm leading-6 text-muted-foreground">
This request could not be resolved. Try again or create a new request.
</p>
);
}
function RequestConfirmationResolution({ function RequestConfirmationResolution({
interaction, interaction,
}: { }: {
interaction: RequestConfirmationInteraction; interaction: RequestConfirmationInteraction;
}) { }) {
const outcome = interaction.result?.outcome;
const target = interaction.payload.target ?? null; const target = interaction.payload.target ?? null;
const staleTarget = interaction.result?.staleTarget ?? null;
if (interaction.status === "accepted") { if (interaction.status === "accepted") {
return ( return (
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Confirmed</span> <span className="font-medium">Confirmed</span>
<RequestConfirmationTargetChip interaction={interaction} target={target} /> <RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
</div> </div>
); );
} }
if (interaction.status === "rejected") { if (interaction.status === "rejected") {
return ( return (
<div className="space-y-2"> <ConfirmationDeclinedBlock
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground"> issueId={interaction.issueId}
<span className="font-medium">Declined</span> result={interaction.result}
<RequestConfirmationTargetChip interaction={interaction} target={target} /> target={target}
</div> />
{interaction.result?.reason ? (
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
{interaction.result.reason}
</blockquote>
) : null}
</div>
); );
} }
if (interaction.status === "expired") { if (interaction.status === "expired") {
const expiredByComment = outcome === "superseded_by_comment";
const expiredByTargetChange = outcome === "stale_target";
return ( return (
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100"> <ConfirmationExpiredBlock
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700"> issueId={interaction.issueId}
{expiredByComment ? "Expired by comment" : "Expired by target change"} result={interaction.result}
</div> target={target}
<p className="leading-6"> />
{expiredByComment
? "A board comment superseded this confirmation before it was resolved."
: "The requested target changed before this confirmation was resolved."}
</p>
{expiredByComment && interaction.result?.commentId ? (
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
<a href={`#comment-${interaction.result.commentId}`}>Jump to comment</a>
</Button>
) : null}
{expiredByTargetChange ? (
<div className="flex flex-wrap items-center gap-2">
<RequestConfirmationTargetChip
interaction={interaction}
target={staleTarget}
tone="subtle"
/>
{staleTarget && target ? (
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
) : null}
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
) : null}
</div>
); );
} }
if (interaction.status === "failed") { if (interaction.status === "failed") {
return ( return <ConfirmationFailedBlock />;
<p className="text-sm leading-6 text-muted-foreground">
This request could not be resolved. Try again or create a new request.
</p>
);
} }
return null; return null;
@@ -1188,7 +1226,7 @@ function RequestConfirmationCard({
</div> </div>
) : null} ) : null}
<RequestConfirmationTargetChip <RequestConfirmationTargetChip
interaction={interaction} issueId={interaction.issueId}
target={interaction.payload.target} target={interaction.payload.target}
/> />
</div> </div>
@@ -1289,6 +1327,421 @@ function RequestConfirmationCard({
); );
} }
const CHECKBOX_SUMMARY_LABEL_LIMIT = 8;
function RequestCheckboxConfirmationResolution({
interaction,
}: {
interaction: RequestCheckboxConfirmationInteraction;
}) {
const target = interaction.payload.target ?? null;
if (interaction.status === "accepted") {
const totalOptions = interaction.payload.options.length;
const selectedLabels = getCheckboxConfirmationSelectedLabels({
payload: interaction.payload,
result: interaction.result,
});
const selectedCount = interaction.result?.selectedOptionIds?.length ?? selectedLabels.length;
const visibleLabels = selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT);
const hiddenCount = selectedLabels.length - visibleLabels.length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">
{selectedCount === 0
? "Confirmed with no options selected"
: `Confirmed ${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"}`}
</span>
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
</div>
{visibleLabels.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{visibleLabels.map((label, index) => (
<TaskField key={`${label}-${index}`} label="Selected" value={label} />
))}
{hiddenCount > 0 ? (
<span className="inline-flex items-center rounded-sm border border-border/60 bg-transparent px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
+{hiddenCount} more
</span>
) : null}
</div>
) : null}
</div>
);
}
if (interaction.status === "rejected") {
return (
<ConfirmationDeclinedBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "expired") {
return (
<ConfirmationExpiredBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "failed") {
return <ConfirmationFailedBlock />;
}
return null;
}
function CheckboxOptionRow({
id,
label,
description,
checked,
disabled,
onToggle,
}: {
id: string;
label: string;
description?: string | null;
checked: boolean;
disabled: boolean;
onToggle: (checked: boolean) => void;
}) {
return (
<label
htmlFor={id}
className={cn(
"flex cursor-pointer items-start gap-2.5 border-b border-border/60 px-3 py-2 last:border-b-0 transition-colors",
checked ? "bg-sky-500/10" : "hover:bg-sky-500/5",
disabled && "cursor-not-allowed opacity-60",
)}
>
<Checkbox
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={(value) => onToggle(value === true)}
aria-label={label}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-5 text-foreground">{label}</div>
{description ? (
<p className="mt-0.5 text-sm leading-5 text-muted-foreground">{description}</p>
) : null}
</div>
</label>
);
}
function RequestCheckboxConfirmationCard({
interaction,
onAcceptInteraction,
onRejectInteraction,
}: {
interaction: RequestCheckboxConfirmationInteraction;
onAcceptInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
selectedClientKeys: undefined,
selectedOptionIds: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
}) {
const options = interaction.payload.options;
const optionIds = useMemo(() => options.map((option) => option.id), [options]);
const validOptionIds = useMemo(() => new Set(optionIds), [optionIds]);
const minSelected = interaction.payload.minSelected ?? 0;
const maxSelected = interaction.payload.maxSelected ?? null;
const defaultSelected = useMemo(
() =>
new Set(
(interaction.payload.defaultSelectedOptionIds ?? []).filter((id) => validOptionIds.has(id)),
),
[interaction.payload.defaultSelectedOptionIds, validOptionIds],
);
const [selectedOptionIds, setSelectedOptionIds] = useState<Set<string>>(() => new Set(defaultSelected));
const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
const [rejectAttempted, setRejectAttempted] = useState(false);
const [acceptAttempted, setAcceptAttempted] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const optionSeed = useMemo(() => optionIds.join("\n"), [optionIds]);
useEffect(() => {
setSelectedOptionIds(new Set(defaultSelected));
setRejectReason(interaction.result?.reason ?? "");
setRejectAttempted(false);
setAcceptAttempted(false);
setActionError(null);
if (interaction.status !== "pending") {
setRejecting(false);
setWorking(null);
}
// optionSeed guards against option list identity churn for the same interaction.
}, [interaction.id, interaction.status, interaction.result?.reason, defaultSelected, optionSeed]);
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
const trimmedRejectReason = rejectReason.trim();
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0;
const declineReasonInvalid = rejectRequiresReason && !canReject;
const declineReasonPlaceholder =
interaction.payload.declineReasonPlaceholder ?? "Optional: tell the agent what you'd change.";
const selectedCount = selectedOptionIds.size;
const totalOptions = options.length;
const atMax = maxSelected != null && selectedCount >= maxSelected;
const belowMin = selectedCount < minSelected;
const aboveMax = maxSelected != null && selectedCount > maxSelected;
const selectionValid = !belowMin && !aboveMax;
const validationMessage = belowMin
? minSelected === 1
? "Select at least 1 option."
: `Select at least ${minSelected} options.`
: aboveMax && maxSelected != null
? maxSelected === 1
? "Select at most 1 option."
: `Select at most ${maxSelected} options.`
: null;
function toggleOption(optionId: string, checked: boolean) {
setSelectedOptionIds((current) => {
const next = new Set(current);
if (checked) {
next.add(optionId);
} else {
next.delete(optionId);
}
return next;
});
}
function handleSelectAll() {
const capped = maxSelected != null ? optionIds.slice(0, maxSelected) : optionIds;
setSelectedOptionIds(new Set(capped));
}
function handleClearSelection() {
setSelectedOptionIds(new Set());
}
async function handleAccept() {
setAcceptAttempted(true);
if (!onAcceptInteraction || !selectionValid) return;
setWorking("accept");
setActionError(null);
try {
await onAcceptInteraction(interaction, undefined, [...selectedOptionIds]);
} catch {
setActionError("Try again");
} finally {
setWorking(null);
}
}
async function handleReject() {
setRejectAttempted(true);
if (!onRejectInteraction || !canReject) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, trimmedRejectReason || undefined);
setRejecting(false);
} catch {
setActionError("Try again");
} finally {
setWorking(null);
}
}
if (interaction.status !== "pending") {
return (
<div className="space-y-4">
<RequestCheckboxConfirmationResolution interaction={interaction} />
</div>
);
}
const selectionSummary = totalOptions > 0 && selectedCount === totalOptions
? `All ${totalOptions} options selected`
: `${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"} selected`;
const boundsHint = maxSelected != null
? `Pick ${minSelected === maxSelected ? `exactly ${maxSelected}` : `${minSelected}–${maxSelected}`}.`
: minSelected > 0
? `Pick at least ${minSelected}.`
: null;
return (
<div className="space-y-4">
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
{interaction.payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
</div>
) : null}
<RequestConfirmationTargetChip
issueId={interaction.issueId}
target={interaction.payload.target}
/>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{selectionSummary}</span>
{boundsHint ? <span>{boundsHint}</span> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null || selectedCount === totalOptions || (maxSelected != null && selectedCount >= maxSelected)}
onClick={handleSelectAll}
>
Select all
</Button>
<Button
size="sm"
variant="ghost"
disabled={working !== null || selectedCount === 0}
onClick={handleClearSelection}
>
Clear selection
</Button>
</div>
</div>
<div
role="group"
aria-label="Selectable options"
className="max-h-80 overflow-y-auto rounded-sm border border-border/70"
>
{options.map((option) => {
const checked = selectedOptionIds.has(option.id);
return (
<CheckboxOptionRow
key={option.id}
id={`${interaction.id}-${option.id}`}
label={option.label}
description={option.description}
checked={checked}
disabled={working !== null || (!checked && atMax)}
onToggle={(value) => toggleOption(option.id, value)}
/>
);
})}
</div>
{acceptAttempted && validationMessage ? (
<p className="text-xs text-destructive">{validationMessage}</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant={rejecting ? "outline" : "default"}
disabled={!onAcceptInteraction || working !== null}
onClick={() => void handleAccept()}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Confirming...
</>
) : (
interaction.payload.acceptLabel ?? "Confirm selected"
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => {
if (!allowDeclineReason) {
void handleReject();
return;
}
setRejectAttempted(false);
setRejecting((current) => !current);
}}
>
{interaction.payload.rejectLabel ?? "Request changes"}
</Button>
</div>
{rejecting ? (
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
<Textarea
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder={declineReasonPlaceholder}
aria-invalid={rejectAttempted && declineReasonInvalid}
className={cn(
"min-h-24 bg-background text-sm",
rejectAttempted && declineReasonInvalid
&& "border-rose-500 focus-visible:ring-rose-500/25",
)}
/>
{rejectAttempted && declineReasonInvalid ? (
<p className="text-xs text-destructive">A reason is required.</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => {
setRejecting(false);
setRejectAttempted(false);
}}
>
Cancel
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => void handleReject()}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
interaction.payload.rejectLabel ?? "Request changes"
)}
</Button>
</div>
</div>
) : null}
{actionError ? (
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
</div>
</div>
);
}
export function IssueThreadInteractionCard({ export function IssueThreadInteractionCard({
interaction, interaction,
agentMap, agentMap,
@@ -1347,7 +1800,9 @@ export function IssueThreadInteractionCard({
? "Suggested task tree" ? "Suggested task tree"
: interaction.kind === "ask_user_questions" : interaction.kind === "ask_user_questions"
? interaction.payload.title ?? "Questions for the operator" ? interaction.payload.title ?? "Questions for the operator"
: "Confirmation requested")} : interaction.kind === "request_checkbox_confirmation"
? "Checkbox confirmation requested"
: "Confirmation requested")}
</div> </div>
{interaction.summary ? ( {interaction.summary ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground"> <p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
@@ -1385,6 +1840,12 @@ export function IssueThreadInteractionCard({
onSubmitInteractionAnswers={onSubmitInteractionAnswers} onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction} onCancelInteraction={onCancelInteraction}
/> />
) : interaction.kind === "request_checkbox_confirmation" ? (
<RequestCheckboxConfirmationCard
interaction={interaction}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : ( ) : (
<RequestConfirmationCard <RequestConfirmationCard
interaction={interaction} interaction={interaction}
@@ -6,6 +6,7 @@ import type {
import type { IssueTimelineEvent } from "../lib/issue-timeline-events"; import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
import type { import type {
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
SuggestTasksInteraction, SuggestTasksInteraction,
} from "../lib/issue-thread-interactions"; } from "../lib/issue-thread-interactions";
@@ -224,6 +225,65 @@ function createRequestConfirmationInteraction(
}; };
} }
function createRequestCheckboxConfirmationInteraction(
overrides: Partial<RequestCheckboxConfirmationInteraction>,
): RequestCheckboxConfirmationInteraction {
return {
id: "interaction-checkbox-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "request_checkbox_confirmation",
title: "Choose the stale drafts to delete",
summary:
"The agent found several stale draft documents and needs the board to confirm exactly which ones to remove.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-codex",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-20T14:46:00.000Z"),
updatedAt: new Date("2026-04-20T14:46:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Check the draft documents you want me to delete.",
detailsMarkdown:
"Only the checked items will be deleted. Leave an item unchecked to keep it for now.",
options: [
{
id: "draft-march-report",
label: "Old draft report",
description: "Created by QA during the March test pass.",
},
{
id: "draft-spec-v1",
label: "Spec v1 (superseded)",
description: "Replaced by the approved v2 specification.",
},
{
id: "draft-scratch-notes",
label: "Scratch notes",
description: "Unstructured notes from the kickoff call.",
},
{
id: "draft-import-sample",
label: "Import sample fixture",
description: "Temporary fixture used while wiring the importer.",
},
],
defaultSelectedOptionIds: [],
minSelected: 0,
maxSelected: null,
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
rejectRequiresReason: false,
},
result: null,
...overrides,
};
}
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({}); export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({ export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
@@ -465,6 +525,145 @@ export const failedRequestConfirmationInteraction = createRequestConfirmationInt
updatedAt: new Date("2026-04-20T14:42:00.000Z"), updatedAt: new Date("2026-04-20T14:42:00.000Z"),
}); });
export const pendingRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({});
export const boundedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-bounded",
title: "Pick the regions to deploy first",
summary: "Choose between two and three regions for the Phase 1 rollout.",
payload: {
version: 1,
prompt: "Which regions should we deploy to initially?",
detailsMarkdown: "Select at least 2 and at most 3 regions.",
options: [
{ id: "us-west", label: "US West (Oregon)", description: "Lowest latency for our primary user base." },
{ id: "us-east", label: "US East (Virginia)", description: "Redundancy and east coast coverage." },
{ id: "eu-west", label: "EU West (Ireland)", description: "GDPR compliance and European users." },
{ id: "ap-southeast", label: "AP Southeast (Singapore)", description: "Asia-Pacific expansion." },
],
defaultSelectedOptionIds: ["us-west", "us-east"],
minSelected: 2,
maxSelected: 3,
acceptLabel: "Confirm regions",
rejectLabel: "Reconsider",
rejectRequiresReason: true,
rejectReasonLabel: "What should change about the region set?",
},
});
export const acceptedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-accepted",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:49:00.000Z"),
updatedAt: new Date("2026-04-20T14:49:00.000Z"),
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["draft-march-report", "draft-spec-v1"],
},
});
const manyOptionList = Array.from({ length: 100 }, (_, index) => {
const number = index + 1;
return {
id: `record-${number}`,
label: `Customer record #${number}`,
description: number % 4 === 0
? "Flagged as a possible duplicate during the last import."
: undefined,
};
});
export const manyOptionsRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-many",
title: "Select the customer records to archive",
summary: "The cleanup job found 100 stale customer records. Confirm which ones to archive.",
payload: {
version: 1,
prompt: "Check every customer record you want archived.",
detailsMarkdown: "The list scrolls. Use Select all / Clear selection to move quickly.",
options: manyOptionList,
defaultSelectedOptionIds: [],
minSelected: 0,
maxSelected: null,
acceptLabel: "Archive selected",
rejectLabel: "Request changes",
rejectRequiresReason: false,
},
});
export const acceptedManyRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-many-accepted",
status: "accepted",
title: "Select the customer records to archive",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:52:00.000Z"),
updatedAt: new Date("2026-04-20T14:52:00.000Z"),
payload: manyOptionsRequestCheckboxConfirmationInteraction.payload,
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: manyOptionList.slice(0, 42).map((option) => option.id),
},
});
export const rejectedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-rejected",
status: "rejected",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:50:00.000Z"),
updatedAt: new Date("2026-04-20T14:50:00.000Z"),
result: {
version: 1,
outcome: "rejected",
reason: "Don't delete anything yet — let me confirm with the data owner first.",
},
});
export const staleTargetRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-stale",
status: "expired",
resolvedByAgentId: "agent-codex",
resolvedAt: new Date("2026-04-20T14:51:00.000Z"),
updatedAt: new Date("2026-04-20T14:51:00.000Z"),
payload: {
version: 1,
prompt: "Check the draft documents you want me to delete.",
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
options: [
{ id: "draft-march-report", label: "Old draft report" },
{ id: "draft-spec-v1", label: "Spec v1 (superseded)" },
],
target: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "44444444-4444-4444-8444-444444444444",
revisionNumber: 4,
},
},
result: {
version: 1,
outcome: "stale_target",
staleTarget: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
revisionNumber: 3,
},
},
});
export const issueThreadInteractionComments: IssueChatComment[] = [ export const issueThreadInteractionComments: IssueChatComment[] = [
createComment({ createComment({
id: "comment-thread-board", id: "comment-thread-board",
@@ -4,7 +4,10 @@ import {
buildSuggestedTaskTree, buildSuggestedTaskTree,
collectSuggestedTaskClientKeys, collectSuggestedTaskClientKeys,
countSuggestedTaskNodes, countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels, getQuestionAnswerLabels,
normalizeRequestConfirmationTargetHref,
} from "./issue-thread-interactions"; } from "./issue-thread-interactions";
describe("buildSuggestedTaskTree", () => { describe("buildSuggestedTaskTree", () => {
@@ -126,6 +129,110 @@ describe("issue thread interaction helpers", () => {
})).toBe("Answered 1 question"); })).toBe("Answered 1 question");
}); });
it("summarizes checkbox confirmation interactions by count", () => {
const base = {
id: "interaction-checkbox",
companyId: "company-1",
issueId: "issue-1",
kind: "request_checkbox_confirmation" as const,
continuationPolicy: "wake_assignee" as const,
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: {
version: 1 as const,
prompt: "Pick items",
options: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
{ id: "c", label: "C" },
],
},
};
expect(buildIssueThreadInteractionSummary({ ...base, status: "pending" }))
.toBe("Requested a selection from 3 options");
expect(buildIssueThreadInteractionSummary({
...base,
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: ["a", "c"] },
})).toBe("Confirmed 2 of 3 options");
expect(buildIssueThreadInteractionSummary({
...base,
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: [] },
})).toBe("Confirmed with no options selected");
expect(buildIssueThreadInteractionSummary({
...base,
status: "expired",
result: { version: 1, outcome: "stale_target" },
})).toBe("Selection expired after target changed");
});
it("maps selected checkbox option ids back to labels", () => {
const labels = getCheckboxConfirmationSelectedLabels({
payload: {
version: 1,
prompt: "Pick items",
options: [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Bravo" },
{ id: "c", label: "Charlie" },
],
},
result: { version: 1, outcome: "accepted", selectedOptionIds: ["c", "a", "missing"] },
});
expect(labels).toEqual(["Charlie", "Alpha"]);
});
it("allows only safe confirmation target hrefs for rendering", () => {
for (const href of [
"https://example.com/checklist",
"http://example.com/checklist",
"/PAP/issues/PAP-123#document-plan",
"#document-plan",
]) {
expect(normalizeRequestConfirmationTargetHref(href)).toBe(href);
}
for (const href of [
"file:///tmp/x",
"mailto:user@example.com",
"slack://channel?id=1",
"vscode://file/tmp/x",
"ftp://example.com/file",
"//evil.example/path",
]) {
expect(normalizeRequestConfirmationTargetHref(href)).toBeNull();
}
});
it("does not return unsafe custom target hrefs from accepted payloads", () => {
expect(getRequestConfirmationTargetHref({
issueId: "issue-1",
target: {
type: "custom",
key: "unsafe-target",
label: "Unsafe target",
href: "file:///tmp/x",
},
})).toBeNull();
expect(getRequestConfirmationTargetHref({
issueId: "issue-1",
target: {
type: "issue_document",
issueId: "issue-2",
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
href: "slack://channel?id=1",
},
})).toBe("/issues/issue-2#document-plan");
});
it("maps stored option ids back to labels for answered summaries", () => { it("maps stored option ids back to labels for answered summaries", () => {
const labels = getQuestionAnswerLabels({ const labels = getQuestionAnswerLabels({
question: { question: {
+68
View File
@@ -10,6 +10,10 @@ export type {
IssueThreadInteractionBase, IssueThreadInteractionBase,
IssueThreadInteractionContinuationPolicy, IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionStatus, IssueThreadInteractionStatus,
RequestCheckboxConfirmationInteraction,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction, RequestConfirmationInteraction,
RequestConfirmationIssueDocumentTarget, RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload, RequestConfirmationPayload,
@@ -26,7 +30,10 @@ import type {
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
AskUserQuestionsQuestion, AskUserQuestionsQuestion,
IssueThreadInteraction, IssueThreadInteraction,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction, RequestConfirmationInteraction,
RequestConfirmationTarget,
SuggestedTaskDraft, SuggestedTaskDraft,
SuggestTasksInteraction, SuggestTasksInteraction,
SuggestTasksResultCreatedTask, SuggestTasksResultCreatedTask,
@@ -49,9 +56,49 @@ export function isIssueThreadInteraction(
candidate.kind === "suggest_tasks" candidate.kind === "suggest_tasks"
|| candidate.kind === "ask_user_questions" || candidate.kind === "ask_user_questions"
|| candidate.kind === "request_confirmation" || candidate.kind === "request_confirmation"
|| candidate.kind === "request_checkbox_confirmation"
); );
} }
export function getCheckboxConfirmationSelectedLabels(args: {
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}): string[] {
const { payload, result } = args;
const selectedIds = result?.selectedOptionIds ?? [];
const optionLabelById = new Map(
payload.options.map((option) => [option.id, option.label] as const),
);
return selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
}
export function normalizeRequestConfirmationTargetHref(href: string) {
const value = href.trim();
if (value.startsWith("#")) return value;
if (value.startsWith("/")) return value.startsWith("//") ? null : value;
return /^https?:\/\//i.test(value) ? value : null;
}
export function getRequestConfirmationTargetHref({
issueId,
target,
}: {
issueId: string;
target: RequestConfirmationTarget;
}) {
if (target.href) {
const safeHref = normalizeRequestConfirmationTargetHref(target.href);
if (safeHref) return safeHref;
}
if (target.type === "issue_document") {
const targetIssueId = target.issueId ?? issueId;
return `/issues/${targetIssueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
export function buildIssueThreadInteractionSummary( export function buildIssueThreadInteractionSummary(
interaction: IssueThreadInteraction, interaction: IssueThreadInteraction,
) { ) {
@@ -83,6 +130,27 @@ export function buildIssueThreadInteractionSummary(
return "Requested confirmation"; return "Requested confirmation";
} }
if (interaction.kind === "request_checkbox_confirmation") {
const optionCount = interaction.payload.options.length;
if (interaction.status === "accepted") {
const selectedCount = interaction.result?.selectedOptionIds?.length ?? 0;
if (selectedCount === 0) return "Confirmed with no options selected";
return selectedCount === 1
? `Confirmed 1 of ${optionCount} options`
: `Confirmed ${selectedCount} of ${optionCount} options`;
}
if (interaction.status === "rejected") return "Declined selection";
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Selection expired after comment";
if (outcome === "stale_target") return "Selection expired after target changed";
return "Selection expired";
}
return optionCount === 1
? "Requested a selection from 1 option"
: `Requested a selection from ${optionCount} options`;
}
const count = interaction.payload.questions.length; const count = interaction.payload.questions.length;
if (interaction.status === "answered") { if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`; return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
+13 -3
View File
@@ -156,13 +156,17 @@ import {
type IssueWorkProduct, type IssueWorkProduct,
type IssueWorkMode, type IssueWorkMode,
type IssueThreadInteraction, type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction, type RequestConfirmationInteraction,
type SuggestTasksInteraction, type SuggestTasksInteraction,
type IssueTreeControlMode, type IssueTreeControlMode,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment; type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction; type ActionableIssueThreadInteraction =
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled"; type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null; runId?: string | null;
@@ -674,6 +678,7 @@ type IssueDetailChatTabProps = {
onAcceptInteraction: ( onAcceptInteraction: (
interaction: ActionableIssueThreadInteraction, interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[], selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void>; ) => Promise<void>;
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>; onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
onSubmitInteractionAnswers: ( onSubmitInteractionAnswers: (
@@ -2147,10 +2152,12 @@ export function IssueDetail() {
mutationFn: ({ mutationFn: ({
interaction, interaction,
selectedClientKeys, selectedClientKeys,
selectedOptionIds,
}: { }: {
interaction: ActionableIssueThreadInteraction; interaction: ActionableIssueThreadInteraction;
selectedClientKeys?: string[]; selectedClientKeys?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }), selectedOptionIds?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys, selectedOptionIds }),
onSuccess: (interaction) => { onSuccess: (interaction) => {
upsertInteractionInCache(interaction); upsertInteractionInCache(interaction);
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) { if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
@@ -2167,6 +2174,8 @@ export function IssueDetail() {
pushToast({ pushToast({
title: interaction.kind === "request_confirmation" title: interaction.kind === "request_confirmation"
? "Request confirmed" ? "Request confirmed"
: interaction.kind === "request_checkbox_confirmation"
? "Selection confirmed"
: skippedCount > 0 : skippedCount > 0
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}` ? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
: "Suggested tasks accepted", : "Suggested tasks accepted",
@@ -3081,8 +3090,9 @@ export function IssueDetail() {
const handleAcceptInteraction = useCallback(async ( const handleAcceptInteraction = useCallback(async (
interaction: ActionableIssueThreadInteraction, interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[], selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => { ) => {
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys }); await acceptInteraction.mutateAsync({ interaction, selectedClientKeys, selectedOptionIds });
}, [acceptInteraction]); }, [acceptInteraction]);
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => { const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
await rejectInteraction.mutateAsync({ interaction, reason }); await rejectInteraction.mutateAsync({ interaction, reason });
@@ -4,9 +4,12 @@ import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard"; import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
acceptedManyRequestCheckboxConfirmationInteraction,
acceptedRequestCheckboxConfirmationInteraction,
acceptedSuggestedTasksInteraction, acceptedSuggestedTasksInteraction,
answeredAskUserQuestionsInteraction, answeredAskUserQuestionsInteraction,
acceptedRequestConfirmationInteraction, acceptedRequestConfirmationInteraction,
boundedRequestCheckboxConfirmationInteraction,
commentExpiredRequestConfirmationInteraction, commentExpiredRequestConfirmationInteraction,
failedRequestConfirmationInteraction, failedRequestConfirmationInteraction,
genericPendingRequestConfirmationInteraction, genericPendingRequestConfirmationInteraction,
@@ -15,20 +18,25 @@ import {
issueThreadInteractionFixtureMeta, issueThreadInteractionFixtureMeta,
issueThreadInteractionLiveRuns, issueThreadInteractionLiveRuns,
issueThreadInteractionTranscriptsByRunId, issueThreadInteractionTranscriptsByRunId,
manyOptionsRequestCheckboxConfirmationInteraction,
mixedIssueThreadInteractions, mixedIssueThreadInteractions,
optionalDeclineRequestConfirmationInteraction, optionalDeclineRequestConfirmationInteraction,
pendingAskUserQuestionsInteraction, pendingAskUserQuestionsInteraction,
pendingRequestCheckboxConfirmationInteraction,
pendingRequestConfirmationInteraction, pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction, pendingSuggestedTasksInteraction,
planApprovalAcceptedRequestConfirmationInteraction, planApprovalAcceptedRequestConfirmationInteraction,
rejectedNoReasonRequestConfirmationInteraction, rejectedNoReasonRequestConfirmationInteraction,
rejectedRequestCheckboxConfirmationInteraction,
rejectedRequestConfirmationInteraction, rejectedRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction, rejectedSuggestedTasksInteraction,
staleTargetRequestCheckboxConfirmationInteraction,
staleTargetRequestConfirmationInteraction, staleTargetRequestConfirmationInteraction,
} from "@/fixtures/issueThreadInteractionFixtures"; } from "@/fixtures/issueThreadInteractionFixtures";
import type { import type {
AskUserQuestionsAnswer, AskUserQuestionsAnswer,
AskUserQuestionsInteraction, AskUserQuestionsInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction, RequestConfirmationInteraction,
SuggestTasksInteraction, SuggestTasksInteraction,
} from "@/lib/issue-thread-interactions"; } from "@/lib/issue-thread-interactions";
@@ -191,6 +199,47 @@ function InteractiveRequestConfirmationCard() {
); );
} }
function InteractiveRequestCheckboxConfirmationCard({
pending,
accepted,
rejected,
}: {
pending: RequestCheckboxConfirmationInteraction;
accepted: RequestCheckboxConfirmationInteraction;
rejected: RequestCheckboxConfirmationInteraction;
}) {
const [interaction, setInteraction] = useState<RequestCheckboxConfirmationInteraction>(pending);
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={(_interaction, _selectedClientKeys, selectedOptionIds) =>
setInteraction({
...accepted,
payload: pending.payload,
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: selectedOptionIds ?? [],
},
})}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejected,
payload: pending.payload,
result: {
version: 1,
outcome: "rejected",
reason: reason || rejected.result?.reason || null,
},
})}
/>
);
}
function AutoOpenDeclineRequestConfirmationCard({ function AutoOpenDeclineRequestConfirmationCard({
interaction, interaction,
}: { }: {
@@ -224,7 +273,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Interaction cards for `suggest_tasks`, `ask_user_questions`, and `request_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.", "Interaction cards for `suggest_tasks`, `ask_user_questions`, `request_confirmation`, and `request_checkbox_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
}, },
}, },
}, },
@@ -524,6 +573,129 @@ export const RequestConfirmationFailed: Story = {
export const RequestConfirmationAccepted = RequestConfirmationConfirmed; export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason; export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
export const CheckboxConfirmationPending: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending checkbox confirmation"
description="Board users select any number of options, with frontend-owned select-all and clear controls."
>
<InteractiveRequestCheckboxConfirmationCard
pending={pendingRequestCheckboxConfirmationInteraction}
accepted={acceptedRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationBounded: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Min/max constrained selection"
description="The card enforces minimum and maximum selection counts and requires a decline reason."
>
<InteractiveRequestCheckboxConfirmationCard
pending={boundedRequestCheckboxConfirmationInteraction}
accepted={acceptedRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationAccepted: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted checkbox confirmation"
description="The resolved state leads with a count and lists the selected labels."
>
<IssueThreadInteractionCard
interaction={acceptedRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationAcceptedMany: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted large selection"
description="Large resolved selections summarize by count first and bound the inline chips."
>
<IssueThreadInteractionCard
interaction={acceptedManyRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationRejected: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Declined checkbox confirmation"
description="The decline reason stays attached to the request in the thread."
>
<IssueThreadInteractionCard
interaction={rejectedRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationStaleTarget: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Expired by target change"
description="The watched plan revision moved before the selection was confirmed."
>
<IssueThreadInteractionCard
interaction={staleTargetRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationManyOptions: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Around 100 options"
description="The list stays compact inside a bounded scroll region even with 100 options."
>
<InteractiveRequestCheckboxConfirmationCard
pending={manyOptionsRequestCheckboxConfirmationInteraction}
accepted={acceptedManyRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const ReviewSurface: Story = { export const ReviewSurface: Story = {
render: () => ( render: () => (
<StoryFrame> <StoryFrame>