diff --git a/cli/src/__tests__/issue-subresources.test.ts b/cli/src/__tests__/issue-subresources.test.ts index afa9e411c00..fafd5de661f 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/commands/client/issue.ts b/cli/src/commands/client/issue.ts index a7bdf52fdcf..cd7559444f6 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 c833a412ec7..a7d75081965 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 17f8749b3e4..28dbda8af57 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 bc4ae6dbb98..b6a53418759 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 90af99b4803..1951205526c 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 a135d1f0a9c..cbb223d96c2 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 ab0026b283f..e661c001b0e 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, + }, + }); + }); +});