diff --git a/cli/src/__tests__/issue-subresources.test.ts b/cli/src/__tests__/issue-subresources.test.ts index afa9e411..fafd5de6 100644 --- a/cli/src/__tests__/issue-subresources.test.ts +++ b/cli/src/__tests__/issue-subresources.test.ts @@ -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, "--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:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]); 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/${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}/cancel`], ["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`], ["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"], + }); }); }); diff --git a/cli/src/__tests__/project-goal.test.ts b/cli/src/__tests__/project-goal.test.ts index 53cec698..2303a855 100644 --- a/cli/src/__tests__/project-goal.test.ts +++ b/cli/src/__tests__/project-goal.test.ts @@ -24,6 +24,7 @@ describe("project and goal commands", () => { vi.restoreAllMocks(); delete process.env.PAPERCLIP_API_KEY; delete process.env.PAPERCLIP_API_URL; + delete process.env.PAPERCLIP_COMPANY_ID; }); afterEach(() => { diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index a7bdf52f..cd755944 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -149,6 +149,7 @@ interface IssueRecoveryResolveOptions extends BaseClientOptions { interface InteractionAcceptOptions extends BaseClientOptions { selectedClientKeys?: string; + selectedOptionIds?: string; } interface InteractionReasonOptions extends BaseClientOptions { @@ -745,11 +746,13 @@ export function registerIssueCommands(program: Command): void { .argument("", "Issue ID") .argument("", "Interaction ID") .option("--selected-client-keys ", "Client keys to accept") + .option("--selected-option-ids ", "Checkbox option IDs to accept") .action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => { try { const ctx = resolveCommandContext(opts); const payload = acceptIssueThreadInteractionSchema.parse({ 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); printOutput(interaction, { json: ctx.json }); @@ -761,7 +764,7 @@ export function registerIssueCommands(program: Command): void { for (const [name, action, schema, description] of [ ["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) { addCommonClientOptions( issue diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index c833a412..a7d75081 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -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 () => { const fetchMock = vi.fn().mockResolvedValue( mockJsonResponse({ id: "approval-1" }), diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 17f8749b..28dbda8a 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -6,6 +6,7 @@ import { createApprovalSchema, createIssueInputSchema, issueThreadInteractionContinuationPolicySchema, + requestCheckboxConfirmationPayloadSchema, requestConfirmationPayloadSchema, suggestTasksPayloadSchema, updateIssueSchema, @@ -144,6 +145,17 @@ const createRequestConfirmationToolSchema = z.object({ 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({ approvalId: approvalIdSchema, 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( "paperclipUpsertIssueDocument", "Create or update an issue document", diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index bc4ae6db..b6a53418 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1680,6 +1680,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { async requestConfirmation(issueId, interaction, companyId, options) { return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise; }, + async requestCheckboxConfirmation(issueId, interaction, companyId, options) { + return this.createInteraction(issueId, { ...interaction, kind: "request_checkbox_confirmation" }, companyId, options) as Promise; + }, documents: { async list(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 90af99b4..19512055 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -27,6 +27,7 @@ import type { SuggestTasksInteraction, AskUserQuestionsInteraction, RequestConfirmationInteraction, + RequestCheckboxConfirmationInteraction, CreateIssueThreadInteraction, PluginIssueOriginKind, IssueSurfaceVisibility, @@ -122,6 +123,7 @@ export type { SuggestTasksInteraction, AskUserQuestionsInteraction, RequestConfirmationInteraction, + RequestCheckboxConfirmationInteraction, CreateIssueThreadInteraction, PluginIssueOriginKind, IssueSurfaceVisibility, @@ -1321,7 +1323,7 @@ export interface PluginIssueSummariesClient { * - `issues.orchestration.read` for orchestration summaries * - `issue.comments.read` for `listComments` * - `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.write` for `documents.upsert` and `documents.delete` */ @@ -1454,6 +1456,12 @@ export interface PluginIssuesClient { companyId: string, options?: { authorAgentId?: string }, ): Promise; + requestCheckboxConfirmation( + issueId: string, + interaction: Omit, "kind">, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ documents: PluginIssueDocumentsClient; /** Read and write blocker relationships. */ diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index a135d1f0..cbb223d9 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -43,6 +43,7 @@ import { fileURLToPath } from "node:url"; import type { AskUserQuestionsInteraction, PaperclipPluginManifestV1, + RequestCheckboxConfirmationInteraction, RequestConfirmationInteraction, SuggestTasksInteraction, } from "@paperclipai/shared"; @@ -913,6 +914,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }) as Promise; }, + async requestCheckboxConfirmation( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction: { + ...interaction, + kind: "request_checkbox_confirmation", + }, + authorAgentId: options?.authorAgentId, + }) as Promise; + }, + documents: { async list(issueId: string, companyId: string) { return callHost("issues.documents.list", { issueId, companyId }); diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts index ab0026b2..e661c001 100644 --- a/packages/plugins/sdk/tests/testing-actions.test.ts +++ b/packages/plugins/sdk/tests/testing-actions.test.ts @@ -72,3 +72,59 @@ describe("createTestHarness action context", () => { 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, + }, + }); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index fb58a85b..21b004ea 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -179,9 +179,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [ "suggest_tasks", "ask_user_questions", "request_confirmation", + "request_checkbox_confirmation", ] as const; export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; +export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200; + export const ISSUE_THREAD_INTERACTION_STATUSES = [ "pending", "accepted", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1cd4d297..8a74b5f6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -179,6 +179,7 @@ export { type IssueThreadInteractionKind, type IssueThreadInteractionStatus, type IssueThreadInteractionContinuationPolicy, + REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT, type BuiltInIssueOriginKind, type PluginIssueOriginKind, type IssueOriginKind, @@ -531,6 +532,9 @@ export type { RequestConfirmationTarget, RequestConfirmationPayload, RequestConfirmationResult, + RequestCheckboxConfirmationOption, + RequestCheckboxConfirmationPayload, + RequestCheckboxConfirmationResult, AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, @@ -541,6 +545,7 @@ export type { SuggestTasksInteraction, AskUserQuestionsInteraction, RequestConfirmationInteraction, + RequestCheckboxConfirmationInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, @@ -977,6 +982,9 @@ export { requestConfirmationTargetSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, + requestCheckboxConfirmationOptionSchema, + requestCheckboxConfirmationPayloadSchema, + requestCheckboxConfirmationResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index f17ee0ec..66ca57ea 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { createIssueThreadInteractionSchema } from "./validators/issue.js"; +import { + acceptIssueThreadInteractionSchema, + createIssueThreadInteractionSchema, +} from "./validators/issue.js"; describe("issue thread interaction schemas", () => { 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", () => { - const parsed = createIssueThreadInteractionSchema.parse({ - kind: "request_confirmation", - payload: { - version: 1, - prompt: "Proceed with the external checklist?", - target: { - type: "custom", - key: "external-checklist", - revisionId: "checklist-v1", - revisionNumber: 1, - label: "Checklist v1", - href: "https://example.com/checklist", + for (const href of [ + "https://example.com/checklist", + "http://example.com/checklist", + "/PAP/issues/PAP-123#document-plan", + "#document-plan", + ]) { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the external checklist?", + target: { + type: "custom", + key: "external-checklist", + revisionId: "checklist-v1", + revisionNumber: 1, + label: "Checklist v1", + href, + }, }, - }, - }); + }); - expect(parsed.kind).toBe("request_confirmation"); - if (parsed.kind !== "request_confirmation") return; - expect(parsed.payload.target).toMatchObject({ - type: "custom", - key: "external-checklist", - label: "Checklist v1", - }); + expect(parsed.kind).toBe("request_confirmation"); + if (parsed.kind !== "request_confirmation") return; + expect(parsed.payload.target).toMatchObject({ + type: "custom", + key: "external-checklist", + label: "Checklist v1", + href, + }); + } }); it("rejects unsafe request_confirmation target hrefs", () => { @@ -107,7 +118,16 @@ describe("issue thread interaction schemas", () => { }, } 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({ ...base, payload: { @@ -117,7 +137,134 @@ describe("issue thread interaction schemas", () => { 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"); + }); }); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9526d13b..5427cbfe 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -288,6 +288,9 @@ export type { RequestConfirmationTarget, RequestConfirmationPayload, RequestConfirmationResult, + RequestCheckboxConfirmationOption, + RequestCheckboxConfirmationPayload, + RequestCheckboxConfirmationResult, AcceptedPlanDecompositionStatus, AcceptedPlanDecompositionChild, AcceptedPlanDecomposition, @@ -298,6 +301,7 @@ export type { SuggestTasksInteraction, AskUserQuestionsInteraction, RequestConfirmationInteraction, + RequestCheckboxConfirmationInteraction, IssueThreadInteraction, IssueThreadInteractionPayload, IssueThreadInteractionResult, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 0df5a30b..100df099 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -786,6 +786,30 @@ export interface RequestConfirmationPayload { 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 { version: 1; outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target"; @@ -794,6 +818,10 @@ export interface RequestConfirmationResult { staleTarget?: RequestConfirmationTarget | null; } +export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult { + selectedOptionIds?: string[]; +} + export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { id: string; companyId: string; @@ -829,20 +857,29 @@ export interface RequestConfirmationInteraction extends IssueThreadInteractionBa result?: RequestConfirmationResult | null; } +export interface RequestCheckboxConfirmationInteraction extends IssueThreadInteractionBase { + kind: "request_checkbox_confirmation"; + payload: RequestCheckboxConfirmationPayload; + result?: RequestCheckboxConfirmationResult | null; +} + export type IssueThreadInteraction = | SuggestTasksInteraction | AskUserQuestionsInteraction - | RequestConfirmationInteraction; + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction; export type IssueThreadInteractionPayload = | SuggestTasksPayload | AskUserQuestionsPayload - | RequestConfirmationPayload; + | RequestConfirmationPayload + | RequestCheckboxConfirmationPayload; export type IssueThreadInteractionResult = | SuggestTasksResult | AskUserQuestionsResult - | RequestConfirmationResult; + | RequestConfirmationResult + | RequestCheckboxConfirmationResult; export interface IssueAttachment { id: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 85719b7e..751c757b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -261,6 +261,9 @@ export { requestConfirmationTargetSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, + requestCheckboxConfirmationOptionSchema, + requestCheckboxConfirmationPayloadSchema, + requestCheckboxConfirmationResultSchema, createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b750254d..7d7454c4 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -25,6 +25,7 @@ import { ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_STATUSES, MODEL_PROFILE_KEYS, + REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT, } from "../constants.js"; import { multilineTextSchema } from "./text.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 lower = value.toLowerCase(); - return !lower.startsWith("javascript:") - && !lower.startsWith("data:") - && !value.startsWith("//"); -}, "href must not use javascript:, data:, or protocol-relative URLs"); + if (value.startsWith("#")) return true; + if (value.startsWith("/")) return !value.startsWith("//"); + return /^https?:\/\//i.test(value); +}, "href must be a root-relative path, same-page fragment, or http(s) URL"); const requestConfirmationTargetBaseSchema = z.object({ label: z.string().trim().min(1).max(120).nullable().optional(), @@ -727,6 +727,106 @@ export const requestConfirmationPayloadSchema = z.object({ 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(); + 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(); + 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({ version: z.literal(1), outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]), @@ -735,6 +835,25 @@ export const requestConfirmationResultSchema = z.object({ 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(); + 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", [ z.object({ kind: z.literal("suggest_tasks"), @@ -766,12 +885,25 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"), 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; export const acceptIssueThreadInteractionSchema = z.object({ 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) => { const seenClientKeys = new Set(); for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) { @@ -785,6 +917,19 @@ export const acceptIssueThreadInteractionSchema = z.object({ } seenClientKeys.add(clientKey); } + + const seenOptionIds = new Set(); + 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; diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index e7ed587a..a0b12257 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -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 () => { mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" })); mockInteractionService.acceptInteraction.mockResolvedValueOnce({ diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 0f0692d5..845eb42f 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -746,6 +746,164 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { })).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 () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index d97c73e8..9f1cca39 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6033,7 +6033,9 @@ export function issueRoutes( }); } - const acceptedPlanTarget = readAcceptedPlanConfirmationTarget(interaction.payload); + const acceptedPlanTarget = interaction.kind === "request_confirmation" + ? readAcceptedPlanConfirmationTarget(interaction.payload) + : null; const acceptedPlanConfirmation = interaction.kind === "request_confirmation" && interaction.status === "accepted" && @@ -6091,7 +6093,7 @@ export function issueRoutes( rejectionReason: interaction.kind === "suggest_tasks" ? (interaction.result?.rejectionReason ?? null) - : interaction.kind === "request_confirmation" + : interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation" ? (interaction.result?.reason ?? null) : null, }, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 4f84272c..c75071eb 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -16,6 +16,7 @@ import type { CancelIssueThreadInteraction, CreateIssueThreadInteraction, IssueThreadInteraction, + RequestCheckboxConfirmationInteraction, RequestConfirmationInteraction, RequestConfirmationTarget, RejectIssueThreadInteraction, @@ -30,6 +31,8 @@ import { cancelIssueThreadInteractionSchema, createIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, + requestCheckboxConfirmationPayloadSchema, + requestCheckboxConfirmationResultSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, suggestTasksPayloadSchema, @@ -70,6 +73,19 @@ type IssueResolutionContext = { 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 { if (typeof error !== "object" || error === null) return false; const err = error as { code?: string; constraint?: string; constraint_name?: string }; @@ -128,6 +144,13 @@ function hydrateInteraction( payload: requestConfirmationPayloadSchema.parse(row.payload), result: row.result ? requestConfirmationResultSchema.parse(row.result) : null, } 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: throw unprocessable(`Unknown interaction kind: ${row.kind}`); } @@ -149,7 +172,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: { current: IssueThreadInteractionRow; 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.actor.userId) return false; if (!args.issue.assigneeUserId) return false; @@ -158,19 +181,31 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: { return true; } -function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) { +function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) { return interaction.payload.supersedeOnUserComment === true; } function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction { - if (input.kind !== "request_confirmation") return input; - return { - ...input, - payload: { - ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, - }, - }; + switch (input.kind) { + case "request_confirmation": + return { + ...input, + payload: { + ...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: { @@ -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(); + + 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: { questions: AskUserQuestionsInteraction["payload"]["questions"]; answers: RespondIssueThreadInteraction["answers"]; @@ -401,8 +466,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { row: IssueThreadInteractionRow; actor: InteractionActor; }): Promise { - if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null; - const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction; + if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null; + const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction; const target = interaction.payload.target ?? null; if (!target) return null; if (target.type !== "issue_document") return null; @@ -522,6 +587,7 @@ export function issueThreadInteractionService(db: Db) { async function acceptRequestConfirmation(args: { issue: { id: string; companyId: string }; current: IssueThreadInteractionRow; + input: AcceptIssueThreadInteraction; actor: InteractionActor; }): Promise<{ interaction: IssueThreadInteraction; @@ -535,6 +601,15 @@ export function issueThreadInteractionService(db: Db) { 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(); return db.transaction(async (tx) => { const [updated] = await tx @@ -544,6 +619,7 @@ export function issueThreadInteractionService(db: Db) { result: { version: 1, outcome: "accepted", + ...(selectedOptionIds ? { selectedOptionIds } : {}), }, resolvedByAgentId: args.actor.agentId ?? null, resolvedByUserId: args.actor.userId ?? null, @@ -624,7 +700,7 @@ export function issueThreadInteractionService(db: Db) { return expired; } - const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction; + const interaction = hydrateInteraction(args.current) as RequestConfirmationLikeInteraction; const reason = args.input.reason?.trim() ?? ""; if (interaction.payload.rejectRequiresReason === true && reason.length === 0) { 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, { companyId: issue.companyId, issueId: issue.id, @@ -798,6 +874,21 @@ export function issueThreadInteractionService(db: Db) { const accepted = await acceptRequestConfirmation({ issue, 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, }); return { @@ -970,6 +1061,7 @@ export function issueThreadInteractionService(db: Db) { case "suggest_tasks": return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current); case "request_confirmation": + case "request_checkbox_confirmation": return rejectRequestConfirmation({ issue, current, @@ -1038,12 +1130,12 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - eq(issueThreadInteractions.kind, "request_confirmation"), + inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )); const superseded = rows.filter((row) => { - const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; return ( shouldSupersedeRequestConfirmationOnUserComment(interaction) && isCommentAtOrAfterInteraction({ @@ -1096,7 +1188,7 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - eq(issueThreadInteractions.kind, "request_confirmation"), + inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )), db @@ -1122,7 +1214,7 @@ export function issueThreadInteractionService(db: Db) { } >(); for (const row of rows) { - const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue; const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({ @@ -1182,12 +1274,12 @@ export function issueThreadInteractionService(db: Db) { .where(and( eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id), - eq(issueThreadInteractions.kind, "request_confirmation"), + inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]), eq(issueThreadInteractions.status, "pending"), )); const staleRows = rows.filter((row) => { - const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; const target = interaction.payload.target; if (!target || target.type !== "issue_document") return false; const targetIssueId = target.issueId ?? issue.id; @@ -1206,7 +1298,7 @@ export function issueThreadInteractionService(db: Db) { const now = new Date(); const expired: IssueThreadInteraction[] = []; for (const row of staleRows) { - const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction; const target = interaction.payload.target ?? null; const currentTarget = buildIssueDocumentTargetFromDocument({ issueId: issue.id, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index f52d422c..3898a981 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -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. +## 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 Load `references/workflows.md` when the task matches one of these: diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 531651bd..e50aad34 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -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. - 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 ``` @@ -792,8 +904,8 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | | POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | | 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/:interactionId/accept` | Accept suggested tasks or 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 (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/respond` | Respond to structured questions | | GET | `/api/issues/:issueId/documents` | List issue documents | diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index b3409342..4fc2e7f5 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -213,7 +213,7 @@ export const issuesApi = { acceptInteraction: ( id: string, interactionId: string, - data?: { selectedClientKeys?: string[] }, + data?: { selectedClientKeys?: string[]; selectedOptionIds?: string[] }, ) => api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), rejectInteraction: (id: string, interactionId: string, reason?: string) => diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index a7642d91..aeab5d70 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -59,6 +59,7 @@ import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction, + RequestCheckboxConfirmationInteraction, RequestConfirmationInteraction, SuggestTasksInteraction, } from "../lib/issue-thread-interactions"; @@ -161,11 +162,18 @@ interface IssueChatMessageContext { onDeleteComment?: (commentId: string) => Promise | void; onImageClick?: (src: string) => void; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, selectedClientKeys?: string[], + selectedOptionIds?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( @@ -361,11 +369,18 @@ interface IssueChatThreadProps { stoppingRunId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, selectedClientKeys?: string[], + selectedOptionIds?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 1ba1762a..f4791a1a 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; import { ThemeProvider } from "../context/ThemeContext"; import { TooltipProvider } from "./ui/tooltip"; import { + acceptedManyRequestCheckboxConfirmationInteraction, + boundedRequestCheckboxConfirmationInteraction, pendingAskUserQuestionsInteraction, commentExpiredRequestConfirmationInteraction, disabledDeclineReasonRequestConfirmationInteraction, failedRequestConfirmationInteraction, + manyOptionsRequestCheckboxConfirmationInteraction, + pendingRequestCheckboxConfirmationInteraction, pendingRequestConfirmationInteraction, pendingSuggestedTasksInteraction, + staleTargetRequestCheckboxConfirmationInteraction, staleTargetRequestConfirmationInteraction, rejectedSuggestedTasksInteraction, } from "../fixtures/issueThreadInteractionFixtures"; @@ -338,4 +343,143 @@ describe("IssueThreadInteractionCard", () => { "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); + }); }); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 73239fcf..a26ac400 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -7,11 +7,15 @@ import { buildSuggestedTaskTree, collectSuggestedTaskClientKeys, countSuggestedTaskNodes, + getCheckboxConfirmationSelectedLabels, + getRequestConfirmationTargetHref, getQuestionAnswerLabels, type AskUserQuestionsAnswer, type AskUserQuestionsInteraction, type IssueThreadInteraction, + type RequestCheckboxConfirmationInteraction, type RequestConfirmationInteraction, + type RequestConfirmationResult, type RequestConfirmationTarget, type SuggestTasksInteraction, type SuggestTasksResultCreatedTask, @@ -34,11 +38,18 @@ interface IssueThreadInteractionCardProps { currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, selectedClientKeys?: string[], + selectedOptionIds?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( @@ -96,6 +107,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) { return "Ask user questions"; case "request_confirmation": return "Confirmation"; + case "request_checkbox_confirmation": + return "Checkbox confirmation"; default: return kind; } @@ -971,33 +984,18 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) { 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({ - interaction, + issueId, target, tone = "default", }: { - interaction: RequestConfirmationInteraction; + issueId: string; target: RequestConfirmationTarget | null | undefined; tone?: "default" | "subtle"; }) { if (!target) return null; - const href = requestConfirmationTargetHref({ interaction, target }); + const href = getRequestConfirmationTargetHref({ issueId, target }); 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]", tone === "default" @@ -1027,81 +1025,121 @@ function RequestConfirmationTargetChip({ ); } +function ConfirmationDeclinedBlock({ + issueId, + result, + target, +}: { + issueId: string; + result: RequestConfirmationResult | null | undefined; + target: RequestConfirmationTarget | null; +}) { + return ( +
+
+ Declined + +
+ {result?.reason ? ( +
+ {result.reason} +
+ ) : null} +
+ ); +} + +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 ( +
+
+ {expiredByComment ? "Expired by comment" : "Expired by target change"} +
+

+ {expiredByComment + ? "A board comment superseded this request before it was resolved." + : "The requested target changed before this request was resolved."} +

+ {expiredByComment && result?.commentId ? ( + + ) : null} + {expiredByTargetChange ? ( +
+ + {staleTarget && target ? ( + + ) : null} + +
+ ) : null} +
+ ); +} + +function ConfirmationFailedBlock() { + return ( +

+ This request could not be resolved. Try again or create a new request. +

+ ); +} + function RequestConfirmationResolution({ interaction, }: { interaction: RequestConfirmationInteraction; }) { - const outcome = interaction.result?.outcome; const target = interaction.payload.target ?? null; - const staleTarget = interaction.result?.staleTarget ?? null; if (interaction.status === "accepted") { return (
Confirmed - +
); } if (interaction.status === "rejected") { return ( -
-
- Declined - -
- {interaction.result?.reason ? ( -
- {interaction.result.reason} -
- ) : null} -
+ ); } if (interaction.status === "expired") { - const expiredByComment = outcome === "superseded_by_comment"; - const expiredByTargetChange = outcome === "stale_target"; return ( -
-
- {expiredByComment ? "Expired by comment" : "Expired by target change"} -
-

- {expiredByComment - ? "A board comment superseded this confirmation before it was resolved." - : "The requested target changed before this confirmation was resolved."} -

- {expiredByComment && interaction.result?.commentId ? ( - - ) : null} - {expiredByTargetChange ? ( -
- - {staleTarget && target ? ( - - ) : null} - -
- ) : null} -
+ ); } if (interaction.status === "failed") { - return ( -

- This request could not be resolved. Try again or create a new request. -

- ); + return ; } return null; @@ -1188,7 +1226,7 @@ function RequestConfirmationCard({ ) : null} @@ -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 ( +
+
+ + {selectedCount === 0 + ? "Confirmed with no options selected" + : `Confirmed ${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"}`} + + +
+ {visibleLabels.length > 0 ? ( +
+ {visibleLabels.map((label, index) => ( + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} more + + ) : null} +
+ ) : null} +
+ ); + } + + if (interaction.status === "rejected") { + return ( + + ); + } + + if (interaction.status === "expired") { + return ( + + ); + } + + if (interaction.status === "failed") { + return ; + } + + 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 ( + + ); +} + +function RequestCheckboxConfirmationCard({ + interaction, + onAcceptInteraction, + onRejectInteraction, +}: { + interaction: RequestCheckboxConfirmationInteraction; + onAcceptInteraction?: ( + interaction: RequestCheckboxConfirmationInteraction, + selectedClientKeys: undefined, + selectedOptionIds: string[], + ) => Promise | void; + onRejectInteraction?: ( + interaction: RequestCheckboxConfirmationInteraction, + reason?: string, + ) => Promise | 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>(() => 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(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 ( +
+ +
+ ); + } + + 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 ( +
+
+
{interaction.payload.prompt}
+ {interaction.payload.detailsMarkdown ? ( +
+ {interaction.payload.detailsMarkdown} +
+ ) : null} + +
+ +
+
+
+ {selectionSummary} + {boundsHint ? {boundsHint} : null} +
+
+ + +
+
+ +
+ {options.map((option) => { + const checked = selectedOptionIds.has(option.id); + return ( + toggleOption(option.id, value)} + /> + ); + })} +
+ + {acceptAttempted && validationMessage ? ( +

{validationMessage}

+ ) : null} + +
+ + +
+ + {rejecting ? ( +
+