Implement checkbox confirmation contract

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-06 13:17:48 +00:00
1 parent a4fa0eaf5e
commit 3e2dde1c4e
11 files changed
+631 -27

No files matched your search

+3
View File
@@ -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",
+8
View File
@@ -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,
@@ -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", () => {
@@ -120,4 +123,92 @@ describe("issue thread interaction schemas", () => {
})).toThrow("href must not use javascript:, data:, or protocol-relative URLs");
}
});
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,
minSelected: 2,
maxSelected: 1,
},
})).toThrow("maxSelected must be greater than or equal to minSelected");
});
it("accepts empty checkbox selections and rejects duplicate selected option ids", () => {
expect(acceptIssueThreadInteractionSchema.parse({ selectedOptionIds: [] })).toEqual({
selectedOptionIds: [],
});
expect(() => acceptIssueThreadInteractionSchema.parse({
selectedOptionIds: ["item-1", "item-1"],
})).toThrow("selectedOptionIds must be unique");
});
});
+4
View File
@@ -288,6 +288,9 @@ export type {
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@@ -298,6 +301,7 @@ export type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
+40 -3
View File
@@ -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;
+3
View File
@@ -261,6 +261,9 @@ export {
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
+139
View File
@@ -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";
@@ -727,6 +728,99 @@ 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<string>();
for (const [index, option] of value.options.entries()) {
if (optionIds.has(option.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Option ids must be unique within one checkbox confirmation",
path: ["options", index, "id"],
});
}
optionIds.add(option.id);
}
const defaultSelectedOptionIds = new Set<string>();
for (const [index, optionId] of value.defaultSelectedOptionIds.entries()) {
if (defaultSelectedOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must be unique",
path: ["defaultSelectedOptionIds", index],
});
continue;
}
defaultSelectedOptionIds.add(optionId);
if (!optionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must reference existing option ids",
path: ["defaultSelectedOptionIds", index],
});
}
}
const maxSelected = value.maxSelected ?? null;
if (value.minSelected > value.options.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "minSelected cannot exceed the option count",
path: ["minSelected"],
});
}
if (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 +829,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<string>();
for (const [index, optionId] of selectedOptionIds.entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
}
seenOptionIds.add(optionId);
}
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("suggest_tasks"),
@@ -766,12 +879,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<typeof createIssueThreadInteractionSchema>;
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<string>();
for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) {
@@ -785,6 +911,19 @@ export const acceptIssueThreadInteractionSchema = z.object({
}
seenClientKeys.add(clientKey);
}
const seenOptionIds = new Set<string>();
for (const [index, optionId] of (value.selectedOptionIds ?? []).entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
continue;
}
seenOptionIds.add(optionId);
}
});
export type AcceptIssueThreadInteraction = z.infer<typeof acceptIssueThreadInteractionSchema>;