Add checkbox confirmation helpers

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-06 13:17:48 +00:00
1 parent 3e2dde1c4e
commit 356adb443c
8 files changed
+175 -2

No files matched your search

@@ -163,6 +163,7 @@ describe("issue subresource commands", () => {
]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--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"],
});
});
});
+4 -1
View File
@@ -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("<issueId>", "Issue ID")
.argument("<interactionId>", "Interaction ID")
.option("--selected-client-keys <csv>", "Client keys to accept")
.option("--selected-option-ids <csv>", "Checkbox option IDs to accept")
.action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => {
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
+56
View File
@@ -292,6 +292,62 @@ describe("paperclip MCP tools", () => {
});
});
it("creates request_checkbox_confirmation interactions with checkbox payloads", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "interaction-1", kind: "request_checkbox_confirmation" }),
);
vi.stubGlobal("fetch", fetchMock);
const tool = getTool("paperclipRequestCheckboxConfirmation");
await tool.execute({
issueId: "PAP-1135",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
});
it("creates approvals with the expected company-scoped payload", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "approval-1" }),
+24
View File
@@ -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",
+3
View File
@@ -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<any>;
},
async requestCheckboxConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_checkbox_confirmation" }, companyId, options) as Promise<any>;
},
documents: {
async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
+9 -1
View File
@@ -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<RequestConfirmationInteraction>;
requestCheckboxConfirmation(
issueId: string,
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "request_checkbox_confirmation" }>, "kind">,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
/** Read and write blocker relationships. */
@@ -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<RequestConfirmationInteraction>;
},
async requestCheckboxConfirmation(
issueId: string,
interaction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction> {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction: {
...interaction,
kind: "request_checkbox_confirmation",
},
authorAgentId: options?.authorAgentId,
}) as Promise<RequestCheckboxConfirmationInteraction>;
},
documents: {
async list(issueId: string, companyId: string) {
return callHost("issues.documents.list", { issueId, companyId });
@@ -72,3 +72,59 @@ describe("createTestHarness action context", () => {
await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true });
});
});
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,
},
});
});
});