mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Implement checkbox confirmation contract
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a4fa0eaf5e
commit
3e2dde1c4e
11 files changed
+631
-27
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -261,6 +261,9 @@ export {
|
||||
requestConfirmationTargetSchema,
|
||||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResultSchema,
|
||||
requestCheckboxConfirmationOptionSchema,
|
||||
requestCheckboxConfirmationPayloadSchema,
|
||||
requestCheckboxConfirmationResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
acceptIssueThreadInteractionSchema,
|
||||
rejectIssueThreadInteractionSchema,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user