mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Add request item verdict interactions
This commit is contained in:
1 parent
1e8ede4e1e
commit
f5ab828a48
14 files changed
+1349
-27
No files matched your search
@@ -248,10 +248,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
"request_checkbox_confirmation",
|
||||
"request_item_verdicts",
|
||||
] as const;
|
||||
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
|
||||
|
||||
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
|
||||
export const REQUEST_ITEM_VERDICTS_ITEM_LIMIT = REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT;
|
||||
|
||||
export const ISSUE_THREAD_INTERACTION_STATUSES = [
|
||||
"pending",
|
||||
|
||||
@@ -296,6 +296,7 @@ export {
|
||||
type IssueThreadInteractionStatus,
|
||||
type IssueThreadInteractionContinuationPolicy,
|
||||
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
|
||||
REQUEST_ITEM_VERDICTS_ITEM_LIMIT,
|
||||
type BuiltInIssueOriginKind,
|
||||
type PluginIssueOriginKind,
|
||||
type IssueOriginKind,
|
||||
@@ -770,6 +771,11 @@ export type {
|
||||
RequestCheckboxConfirmationOption,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
RequestItemVerdictValue,
|
||||
RequestItemVerdictsItem,
|
||||
RequestItemVerdictsPayload,
|
||||
RequestItemVerdictsResultItem,
|
||||
RequestItemVerdictsResult,
|
||||
AcceptedPlanDecompositionStatus,
|
||||
AcceptedPlanDecompositionChild,
|
||||
AcceptedPlanDecomposition,
|
||||
@@ -781,6 +787,7 @@ export type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestItemVerdictsInteraction,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionPayload,
|
||||
IssueThreadInteractionResult,
|
||||
@@ -1284,11 +1291,17 @@ export {
|
||||
requestCheckboxConfirmationOptionSchema,
|
||||
requestCheckboxConfirmationPayloadSchema,
|
||||
requestCheckboxConfirmationResultSchema,
|
||||
requestItemVerdictValueSchema,
|
||||
requestItemVerdictsItemSchema,
|
||||
requestItemVerdictsPayloadSchema,
|
||||
requestItemVerdictsResultItemSchema,
|
||||
requestItemVerdictsResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
acceptIssueThreadInteractionSchema,
|
||||
rejectIssueThreadInteractionSchema,
|
||||
cancelIssueThreadInteractionSchema,
|
||||
respondIssueThreadInteractionSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
linkIssueApprovalSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
createIssueWorkProductSchema,
|
||||
@@ -1350,6 +1363,7 @@ export {
|
||||
type RejectIssueThreadInteraction,
|
||||
type CancelIssueThreadInteraction,
|
||||
type RespondIssueThreadInteraction,
|
||||
type SubmitIssueThreadInteractionVerdicts,
|
||||
type LinkIssueApproval,
|
||||
type CreateIssueAttachmentMetadata,
|
||||
type CreateIssueWorkProduct,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
acceptIssueThreadInteractionSchema,
|
||||
askUserQuestionsResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
} from "./validators/issue.js";
|
||||
|
||||
describe("issue thread interaction schemas", () => {
|
||||
@@ -306,4 +307,111 @@ describe("issue thread interaction schemas", () => {
|
||||
selectedOptionIds: ["item-1", "item-1"],
|
||||
})).toThrow("selectedOptionIds must be unique");
|
||||
});
|
||||
|
||||
it("parses request_item_verdicts payloads with defaults", () => {
|
||||
const parsed = createIssueThreadInteractionSchema.parse({
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review these generated items.",
|
||||
items: [
|
||||
{ id: "api", label: "API route", description: "Server submit endpoint" },
|
||||
{ id: "docs", label: "Docs", previewMarkdown: "Document the route." },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
kind: "request_item_verdicts",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
verdicts: ["approve", "reject"],
|
||||
requireReasonOn: ["reject"],
|
||||
allowBulkApprove: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts request_item_verdicts defer when enabled explicitly", () => {
|
||||
const parsed = createIssueThreadInteractionSchema.parse({
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review these generated items.",
|
||||
items: [{ id: "api", label: "API route" }],
|
||||
verdicts: ["approve", "reject", "defer"],
|
||||
requireReasonOn: ["reject", "defer"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
verdicts: ["approve", "reject", "defer"],
|
||||
requireReasonOn: ["reject", "defer"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid request_item_verdicts item and reason references", () => {
|
||||
const base = {
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review these generated items.",
|
||||
items: [
|
||||
{ id: "api", label: "API route" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
expect(() => createIssueThreadInteractionSchema.parse({
|
||||
...base,
|
||||
payload: {
|
||||
...base.payload,
|
||||
items: [],
|
||||
},
|
||||
})).toThrow();
|
||||
|
||||
expect(() => createIssueThreadInteractionSchema.parse({
|
||||
...base,
|
||||
payload: {
|
||||
...base.payload,
|
||||
items: [
|
||||
{ id: "api", label: "API route" },
|
||||
{ id: "api", label: "Duplicate" },
|
||||
],
|
||||
},
|
||||
})).toThrow("Item ids must be unique within one item verdict request");
|
||||
|
||||
expect(() => createIssueThreadInteractionSchema.parse({
|
||||
...base,
|
||||
payload: {
|
||||
...base.payload,
|
||||
items: Array.from({ length: 201 }, (_value, index) => ({
|
||||
id: `item-${index}`,
|
||||
label: `Item ${index}`,
|
||||
})),
|
||||
},
|
||||
})).toThrow();
|
||||
|
||||
expect(() => createIssueThreadInteractionSchema.parse({
|
||||
...base,
|
||||
payload: {
|
||||
...base.payload,
|
||||
verdicts: ["approve", "reject"],
|
||||
requireReasonOn: ["defer"],
|
||||
},
|
||||
})).toThrow("requireReasonOn must reference enabled verdicts");
|
||||
});
|
||||
|
||||
it("rejects duplicate request_item_verdicts submit ids", () => {
|
||||
expect(() => submitIssueThreadInteractionVerdictsSchema.parse({
|
||||
verdicts: [
|
||||
{ id: "api", verdict: "approve" },
|
||||
{ id: "api", verdict: "reject", reason: "Needs revision" },
|
||||
],
|
||||
})).toThrow("verdict item ids must be unique");
|
||||
});
|
||||
});
|
||||
@@ -400,6 +400,11 @@ export type {
|
||||
RequestCheckboxConfirmationOption,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
RequestItemVerdictValue,
|
||||
RequestItemVerdictsItem,
|
||||
RequestItemVerdictsPayload,
|
||||
RequestItemVerdictsResultItem,
|
||||
RequestItemVerdictsResult,
|
||||
AcceptedPlanDecompositionStatus,
|
||||
AcceptedPlanDecompositionChild,
|
||||
AcceptedPlanDecomposition,
|
||||
@@ -411,6 +416,7 @@ export type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestItemVerdictsInteraction,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionPayload,
|
||||
IssueThreadInteractionResult,
|
||||
|
||||
@@ -1082,6 +1082,30 @@ export interface RequestCheckboxConfirmationPayload {
|
||||
target?: RequestConfirmationTarget | null;
|
||||
}
|
||||
|
||||
export type RequestItemVerdictValue = "approve" | "reject" | "defer";
|
||||
|
||||
export interface RequestItemVerdictsItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
previewMarkdown?: string | null;
|
||||
href?: string | null;
|
||||
attachmentId?: string | null;
|
||||
}
|
||||
|
||||
export interface RequestItemVerdictsPayload {
|
||||
version: 1;
|
||||
prompt: string;
|
||||
detailsMarkdown?: string | null;
|
||||
items: RequestItemVerdictsItem[];
|
||||
verdicts?: RequestItemVerdictValue[];
|
||||
requireReasonOn?: RequestItemVerdictValue[];
|
||||
reasonLabel?: string | null;
|
||||
allowBulkApprove?: boolean;
|
||||
supersedeOnUserComment?: boolean;
|
||||
target?: RequestConfirmationTarget | null;
|
||||
}
|
||||
|
||||
export interface RequestConfirmationResult {
|
||||
version: 1;
|
||||
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
|
||||
@@ -1104,6 +1128,24 @@ export interface RequestCheckboxConfirmationResult extends RequestConfirmationRe
|
||||
selectedOptionIds?: string[];
|
||||
}
|
||||
|
||||
export interface RequestItemVerdictsResultItem {
|
||||
id: string;
|
||||
verdict: RequestItemVerdictValue;
|
||||
reason?: string | null;
|
||||
resolvedByUserId: string;
|
||||
resolvedAt: Date | string;
|
||||
commentId?: string | null;
|
||||
}
|
||||
|
||||
export interface RequestItemVerdictsResult {
|
||||
version: 1;
|
||||
outcome: "resolved" | "superseded_by_comment" | "stale_target" | "cancelled";
|
||||
complete: boolean;
|
||||
items: RequestItemVerdictsResultItem[];
|
||||
commentId?: string | null;
|
||||
staleTarget?: RequestConfirmationTarget | null;
|
||||
}
|
||||
|
||||
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -1145,23 +1187,32 @@ export interface RequestCheckboxConfirmationInteraction extends IssueThreadInter
|
||||
result?: RequestCheckboxConfirmationResult | null;
|
||||
}
|
||||
|
||||
export interface RequestItemVerdictsInteraction extends IssueThreadInteractionBase {
|
||||
kind: "request_item_verdicts";
|
||||
payload: RequestItemVerdictsPayload;
|
||||
result?: RequestItemVerdictsResult | null;
|
||||
}
|
||||
|
||||
export type IssueThreadInteraction =
|
||||
| SuggestTasksInteraction
|
||||
| AskUserQuestionsInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction;
|
||||
| RequestCheckboxConfirmationInteraction
|
||||
| RequestItemVerdictsInteraction;
|
||||
|
||||
export type IssueThreadInteractionPayload =
|
||||
| SuggestTasksPayload
|
||||
| AskUserQuestionsPayload
|
||||
| RequestConfirmationPayload
|
||||
| RequestCheckboxConfirmationPayload;
|
||||
| RequestCheckboxConfirmationPayload
|
||||
| RequestItemVerdictsPayload;
|
||||
|
||||
export type IssueThreadInteractionResult =
|
||||
| SuggestTasksResult
|
||||
| AskUserQuestionsResult
|
||||
| RequestConfirmationResult
|
||||
| RequestCheckboxConfirmationResult;
|
||||
| RequestCheckboxConfirmationResult
|
||||
| RequestItemVerdictsResult;
|
||||
|
||||
export interface IssueAttachment {
|
||||
id: string;
|
||||
|
||||
@@ -364,11 +364,17 @@ export {
|
||||
requestCheckboxConfirmationOptionSchema,
|
||||
requestCheckboxConfirmationPayloadSchema,
|
||||
requestCheckboxConfirmationResultSchema,
|
||||
requestItemVerdictValueSchema,
|
||||
requestItemVerdictsItemSchema,
|
||||
requestItemVerdictsPayloadSchema,
|
||||
requestItemVerdictsResultItemSchema,
|
||||
requestItemVerdictsResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
acceptIssueThreadInteractionSchema,
|
||||
rejectIssueThreadInteractionSchema,
|
||||
cancelIssueThreadInteractionSchema,
|
||||
respondIssueThreadInteractionSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
linkIssueApprovalSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
issueDocumentFormatSchema,
|
||||
@@ -391,6 +397,7 @@ export {
|
||||
type RejectIssueThreadInteraction,
|
||||
type CancelIssueThreadInteraction,
|
||||
type RespondIssueThreadInteraction,
|
||||
type SubmitIssueThreadInteractionVerdicts,
|
||||
type LinkIssueApproval,
|
||||
type CreateIssueAttachmentMetadata,
|
||||
type IssueDocumentFormat,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ISSUE_WATCHDOG_DISCOVERY_KINDS,
|
||||
MODEL_PROFILE_KEYS,
|
||||
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
|
||||
REQUEST_ITEM_VERDICTS_ITEM_LIMIT,
|
||||
} from "../constants.js";
|
||||
import { multilineTextSchema } from "./text.js";
|
||||
import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js";
|
||||
@@ -894,6 +895,121 @@ export const requestCheckboxConfirmationResultSchema = requestConfirmationResult
|
||||
}
|
||||
});
|
||||
|
||||
export const requestItemVerdictValueSchema = z.enum(["approve", "reject", "defer"]);
|
||||
|
||||
export const requestItemVerdictsItemSchema = 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(),
|
||||
previewMarkdown: z.string().max(20000).nullable().optional(),
|
||||
href: requestConfirmationHrefSchema.nullable().optional(),
|
||||
attachmentId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export const requestItemVerdictsPayloadSchema = z.object({
|
||||
version: z.literal(1),
|
||||
prompt: z.string().trim().min(1).max(1000),
|
||||
detailsMarkdown: z.string().max(20000).nullable().optional(),
|
||||
items: z.array(requestItemVerdictsItemSchema)
|
||||
.min(1)
|
||||
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
|
||||
verdicts: z.array(requestItemVerdictValueSchema)
|
||||
.min(2)
|
||||
.max(3)
|
||||
.optional()
|
||||
.default(["approve", "reject"]),
|
||||
requireReasonOn: z.array(requestItemVerdictValueSchema)
|
||||
.max(3)
|
||||
.optional()
|
||||
.default(["reject"]),
|
||||
reasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
|
||||
allowBulkApprove: z.boolean().optional().default(true),
|
||||
supersedeOnUserComment: z.boolean().optional(),
|
||||
target: requestConfirmationTargetSchema.nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const itemIds = new Set<string>();
|
||||
for (const [index, item] of value.items.entries()) {
|
||||
if (itemIds.has(item.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Item ids must be unique within one item verdict request",
|
||||
path: ["items", index, "id"],
|
||||
});
|
||||
}
|
||||
itemIds.add(item.id);
|
||||
}
|
||||
|
||||
const verdicts = new Set<string>();
|
||||
for (const [index, verdict] of value.verdicts.entries()) {
|
||||
if (verdicts.has(verdict)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "verdicts must be unique",
|
||||
path: ["verdicts", index],
|
||||
});
|
||||
}
|
||||
verdicts.add(verdict);
|
||||
}
|
||||
if (!verdicts.has("approve") || !verdicts.has("reject")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "verdicts must include approve and reject; defer is optional",
|
||||
path: ["verdicts"],
|
||||
});
|
||||
}
|
||||
|
||||
const reasonVerdicts = new Set<string>();
|
||||
for (const [index, verdict] of value.requireReasonOn.entries()) {
|
||||
if (reasonVerdicts.has(verdict)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "requireReasonOn must be unique",
|
||||
path: ["requireReasonOn", index],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
reasonVerdicts.add(verdict);
|
||||
if (!verdicts.has(verdict)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "requireReasonOn must reference enabled verdicts",
|
||||
path: ["requireReasonOn", index],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const requestItemVerdictsResultItemSchema = z.object({
|
||||
id: z.string().trim().min(1).max(120),
|
||||
verdict: requestItemVerdictValueSchema,
|
||||
reason: z.string().trim().max(4000).nullable().optional(),
|
||||
resolvedByUserId: z.string().trim().min(1).max(255),
|
||||
resolvedAt: z.union([z.string().datetime(), z.date()]),
|
||||
commentId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export const requestItemVerdictsResultSchema = z.object({
|
||||
version: z.literal(1),
|
||||
outcome: z.enum(["resolved", "superseded_by_comment", "stale_target", "cancelled"]),
|
||||
complete: z.boolean(),
|
||||
items: z.array(requestItemVerdictsResultItemSchema)
|
||||
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
|
||||
commentId: z.string().uuid().nullable().optional(),
|
||||
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const itemIds = new Set<string>();
|
||||
for (const [index, item] of value.items.entries()) {
|
||||
if (itemIds.has(item.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "result item ids must be unique",
|
||||
path: ["items", index, "id"],
|
||||
});
|
||||
}
|
||||
itemIds.add(item.id);
|
||||
}
|
||||
});
|
||||
|
||||
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("suggest_tasks"),
|
||||
@@ -935,6 +1051,16 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
|
||||
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
|
||||
payload: requestCheckboxConfirmationPayloadSchema,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("request_item_verdicts"),
|
||||
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: requestItemVerdictsPayloadSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
|
||||
@@ -989,6 +1115,29 @@ export const respondIssueThreadInteractionSchema = z.object({
|
||||
});
|
||||
export type RespondIssueThreadInteraction = z.infer<typeof respondIssueThreadInteractionSchema>;
|
||||
|
||||
export const submitIssueThreadInteractionVerdictsSchema = z.object({
|
||||
verdicts: z.array(z.object({
|
||||
id: z.string().trim().min(1).max(120),
|
||||
verdict: requestItemVerdictValueSchema,
|
||||
reason: z.string().trim().max(4000).nullable().optional(),
|
||||
}))
|
||||
.min(1)
|
||||
.max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
|
||||
}).superRefine((value, ctx) => {
|
||||
const itemIds = new Set<string>();
|
||||
for (const [index, verdict] of value.verdicts.entries()) {
|
||||
if (itemIds.has(verdict.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "verdict item ids must be unique",
|
||||
path: ["verdicts", index, "id"],
|
||||
});
|
||||
}
|
||||
itemIds.add(verdict.id);
|
||||
}
|
||||
});
|
||||
export type SubmitIssueThreadInteractionVerdicts = z.infer<typeof submitIssueThreadInteractionVerdictsSchema>;
|
||||
|
||||
export const linkIssueApprovalSchema = z.object({
|
||||
approvalId: z.string().uuid(),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ const mockInteractionService = vi.hoisted(() => ({
|
||||
rejectSuggestedTasks: vi.fn(),
|
||||
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
|
||||
answerQuestions: vi.fn(),
|
||||
submitItemVerdicts: vi.fn(),
|
||||
cancelQuestions: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -284,6 +285,48 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
updatedAt: "2026-04-20T12:06:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:06:00.000Z",
|
||||
});
|
||||
mockInteractionService.submitItemVerdicts.mockResolvedValue({
|
||||
interaction: {
|
||||
id: "interaction-verdicts",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "request_item_verdicts",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
idempotencyKey: null,
|
||||
sourceCommentId: "comment-verdicts",
|
||||
sourceRunId: "run-verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review generated artifacts.",
|
||||
items: [
|
||||
{ id: "api", label: "API route" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
],
|
||||
verdicts: ["approve", "reject"],
|
||||
requireReasonOn: ["reject"],
|
||||
allowBulkApprove: true,
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: false,
|
||||
items: [
|
||||
{
|
||||
id: "docs",
|
||||
verdict: "reject",
|
||||
reason: "Missing examples",
|
||||
resolvedByUserId: "local-board",
|
||||
resolvedAt: "2026-04-20T12:06:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-20T12:06:00.000Z",
|
||||
resolvedAt: null,
|
||||
},
|
||||
newlyResolvedItemIds: ["docs"],
|
||||
});
|
||||
mockInteractionService.cancelQuestions.mockResolvedValue({
|
||||
id: "interaction-2",
|
||||
companyId: "company-1",
|
||||
@@ -469,6 +512,68 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("submits item verdicts and emits one continuation wake with resolved item ids", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-verdicts/verdicts")
|
||||
.send({
|
||||
verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockInteractionService.submitItemVerdicts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
||||
"interaction-verdicts",
|
||||
{ verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }] },
|
||||
expect.objectContaining({ userId: "local-board" }),
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
idempotencyKey: expect.stringMatching(
|
||||
/^request_item_verdicts:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:interaction-verdicts:/,
|
||||
),
|
||||
payload: expect.objectContaining({
|
||||
interactionId: "interaction-verdicts",
|
||||
interactionKind: "request_item_verdicts",
|
||||
interactionStatus: "pending",
|
||||
sourceCommentId: "comment-verdicts",
|
||||
sourceRunId: "run-verdicts",
|
||||
newlyResolvedItemIds: ["docs"],
|
||||
itemVerdicts: {
|
||||
newlyResolvedItemIds: ["docs"],
|
||||
coalesceWindowMs: 2000,
|
||||
},
|
||||
}),
|
||||
contextSnapshot: expect.objectContaining({
|
||||
interactionId: "interaction-verdicts",
|
||||
interactionKind: "request_item_verdicts",
|
||||
interactionStatus: "pending",
|
||||
newlyResolvedItemIds: ["docs"],
|
||||
itemVerdicts: {
|
||||
newlyResolvedItemIds: ["docs"],
|
||||
coalesceWindowMs: 2000,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.thread_interaction_item_verdicts_submitted",
|
||||
details: expect.objectContaining({
|
||||
interactionKind: "request_item_verdicts",
|
||||
newlyResolvedItemCount: 1,
|
||||
newlyResolvedItemIds: ["docs"],
|
||||
complete: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels question interactions and emits a continuation wake", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
|
||||
@@ -1140,6 +1140,243 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("submits request_item_verdicts partially and completes when all items are resolved", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review generated artifacts.",
|
||||
items: [
|
||||
{ id: "api", label: "API route" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
{ id: "tests", label: "Tests" },
|
||||
],
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
kind: "request_item_verdicts",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
verdicts: ["approve", "reject"],
|
||||
requireReasonOn: ["reject"],
|
||||
allowBulkApprove: true,
|
||||
supersedeOnUserComment: true,
|
||||
},
|
||||
});
|
||||
|
||||
const first = await interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(first.newlyResolvedItemIds).toEqual(["docs"]);
|
||||
expect(first.interaction).toMatchObject({
|
||||
kind: "request_item_verdicts",
|
||||
status: "pending",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: false,
|
||||
items: [
|
||||
{
|
||||
id: "docs",
|
||||
verdict: "reject",
|
||||
reason: "Missing examples",
|
||||
resolvedByUserId: "local-board",
|
||||
},
|
||||
],
|
||||
},
|
||||
resolvedAt: null,
|
||||
});
|
||||
|
||||
const duplicate = await interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "docs", verdict: "reject" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(duplicate.newlyResolvedItemIds).toEqual([]);
|
||||
expect(duplicate.interaction).toMatchObject({
|
||||
status: "pending",
|
||||
result: {
|
||||
complete: false,
|
||||
items: [
|
||||
{
|
||||
id: "docs",
|
||||
verdict: "reject",
|
||||
reason: "Missing examples",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const completed = await interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [
|
||||
{ id: "api", verdict: "approve" },
|
||||
{ id: "tests", verdict: "reject", reason: "No route coverage" },
|
||||
],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(completed.newlyResolvedItemIds).toEqual(["api", "tests"]);
|
||||
expect(completed.interaction).toMatchObject({
|
||||
kind: "request_item_verdicts",
|
||||
status: "answered",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: true,
|
||||
items: [
|
||||
{ id: "api", verdict: "approve" },
|
||||
{ id: "docs", verdict: "reject", reason: "Missing examples" },
|
||||
{ id: "tests", verdict: "reject", reason: "No route coverage" },
|
||||
],
|
||||
},
|
||||
resolvedByUserId: "local-board",
|
||||
});
|
||||
|
||||
const duplicateAfterComplete = await interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "api", verdict: "approve" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
expect(duplicateAfterComplete.newlyResolvedItemIds).toEqual([]);
|
||||
expect(duplicateAfterComplete.interaction.status).toBe("answered");
|
||||
});
|
||||
|
||||
it("enforces request_item_verdicts ids, enabled verdicts, and required reasons", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Item verdict validation");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review generated artifacts.",
|
||||
items: [
|
||||
{ id: "api", label: "API route" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
],
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
await expect(interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "missing", verdict: "approve" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toThrow("Unknown item verdict id: missing");
|
||||
|
||||
await expect(interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "api", verdict: "defer" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toThrow("Verdict defer is not enabled");
|
||||
|
||||
await expect(interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "docs", verdict: "reject" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toThrow("A reason is required when verdict is reject");
|
||||
});
|
||||
|
||||
it("preserves resolved request_item_verdicts items when a later user comment supersedes the pending remainder", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Item verdict supersede");
|
||||
const commentId = randomUUID();
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review generated artifacts.",
|
||||
items: [
|
||||
{ id: "api", label: "API route" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
],
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
await interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "api", verdict: "approve" }],
|
||||
}, {
|
||||
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_item_verdicts",
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
complete: false,
|
||||
commentId,
|
||||
items: [
|
||||
{
|
||||
id: "api",
|
||||
verdict: "approve",
|
||||
resolvedByUserId: "local-board",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
@@ -1580,6 +1817,152 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves resolved request_item_verdicts items when the watched issue document revision changes", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const documentId = randomUUID();
|
||||
const revisionId = randomUUID();
|
||||
const nextRevisionId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Document target verdicts",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "v1",
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: revisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "v1",
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_item_verdicts",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review generated artifacts.",
|
||||
items: [
|
||||
{ id: "api", label: "API route" },
|
||||
{ id: "docs", label: "Docs" },
|
||||
],
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
revisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
await interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "api", verdict: "approve" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
await db.insert(documentRevisions).values({
|
||||
id: nextRevisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 2,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "v2",
|
||||
});
|
||||
await db.update(documents).set({
|
||||
latestBody: "v2",
|
||||
latestRevisionId: nextRevisionId,
|
||||
latestRevisionNumber: 2,
|
||||
});
|
||||
|
||||
const stale = await interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "docs", verdict: "approve" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(stale.newlyResolvedItemIds).toEqual([]);
|
||||
expect(stale.interaction).toMatchObject({
|
||||
id: created.id,
|
||||
status: "expired",
|
||||
payload: {
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: nextRevisionId,
|
||||
revisionNumber: 2,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
complete: false,
|
||||
staleTarget: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: "api",
|
||||
verdict: "approve",
|
||||
resolvedByUserId: "local-board",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace_finalize accept gate", () => {
|
||||
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
rejectIssueThreadInteractionSchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
respondIssueThreadInteractionSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
updateDocumentAnnotationThreadSchema,
|
||||
upsertIssueDocumentSchema,
|
||||
@@ -1766,6 +1767,18 @@ function isAssigneeSelfCommentOnTerminalIssue(input: {
|
||||
return input.actorId === input.assigneeAgentId;
|
||||
}
|
||||
|
||||
const REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS = 2_000;
|
||||
|
||||
function buildRequestItemVerdictsWakeIdempotencyKey(args: {
|
||||
issueId: string;
|
||||
interactionId: string;
|
||||
at?: Date;
|
||||
}) {
|
||||
const now = args.at ?? new Date();
|
||||
const bucket = Math.floor(now.getTime() / REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS);
|
||||
return `request_item_verdicts:${args.issueId}:${args.interactionId}:${bucket}`;
|
||||
}
|
||||
|
||||
function queueResolvedInteractionContinuationWakeup(input: {
|
||||
heartbeat: ReturnType<typeof heartbeatService>;
|
||||
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||
@@ -1783,6 +1796,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
source: string;
|
||||
forceFreshSession?: boolean;
|
||||
workspaceRefreshReason?: string | null;
|
||||
newlyResolvedItemIds?: string[];
|
||||
idempotencyKey?: string | null;
|
||||
}) {
|
||||
if (
|
||||
input.interaction.continuationPolicy !== "wake_assignee"
|
||||
@@ -1800,6 +1815,13 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id);
|
||||
const interactionResult = readConfirmationResultForWake(input.interaction.result);
|
||||
const checkboxSelection = readCheckboxSelectionForWake(input.interaction);
|
||||
const newlyResolvedItemIds = input.newlyResolvedItemIds?.filter((value) => value.length > 0) ?? [];
|
||||
const itemVerdicts = newlyResolvedItemIds.length > 0
|
||||
? {
|
||||
newlyResolvedItemIds,
|
||||
coalesceWindowMs: REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS,
|
||||
}
|
||||
: null;
|
||||
const planReviewInteraction =
|
||||
planTarget && input.interaction.kind === "request_confirmation"
|
||||
? {
|
||||
@@ -1824,8 +1846,10 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
sourceRunId: input.interaction.sourceRunId ?? null,
|
||||
...(planReviewInteraction ? { planReviewInteraction } : {}),
|
||||
...(checkboxSelection ? { checkboxSelection } : {}),
|
||||
...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
|
||||
mutation: "interaction",
|
||||
},
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
requestedByActorType: input.actor.actorType,
|
||||
requestedByActorId: input.actor.actorId,
|
||||
contextSnapshot: {
|
||||
@@ -1838,6 +1862,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
sourceRunId: input.interaction.sourceRunId ?? null,
|
||||
...(planReviewInteraction ? { planReviewInteraction } : {}),
|
||||
...(checkboxSelection ? { checkboxSelection } : {}),
|
||||
...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
|
||||
wakeReason: "issue_commented",
|
||||
source: input.source,
|
||||
...(forceFreshSession ? { forceFreshSession: true } : {}),
|
||||
@@ -9181,6 +9206,76 @@ export function issueRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/issues/:id/interactions/:interactionId/verdicts",
|
||||
validate(submitIssueThreadInteractionVerdictsSchema),
|
||||
async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const interactionId = req.params.interactionId as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return;
|
||||
assertBoard(req);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const { interaction, newlyResolvedItemIds } = await issueThreadInteractionService(db).submitItemVerdicts(
|
||||
issue,
|
||||
interactionId,
|
||||
req.body,
|
||||
{
|
||||
agentId: actor.agentId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: interaction.status === "expired"
|
||||
? "issue.thread_interaction_expired"
|
||||
: "issue.thread_interaction_item_verdicts_submitted",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
interactionId: interaction.id,
|
||||
interactionKind: interaction.kind,
|
||||
interactionStatus: interaction.status,
|
||||
submittedVerdictCount: Array.isArray(req.body?.verdicts) ? req.body.verdicts.length : 0,
|
||||
newlyResolvedItemCount: newlyResolvedItemIds.length,
|
||||
newlyResolvedItemIds,
|
||||
complete:
|
||||
interaction.kind === "request_item_verdicts"
|
||||
? (interaction.result?.complete ?? false)
|
||||
: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (newlyResolvedItemIds.length > 0) {
|
||||
queueResolvedInteractionContinuationWakeup({
|
||||
heartbeat,
|
||||
issue,
|
||||
interaction,
|
||||
actor,
|
||||
source: "issue.interaction.verdicts",
|
||||
newlyResolvedItemIds,
|
||||
idempotencyKey: buildRequestItemVerdictsWakeIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
interactionId: interaction.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(interaction);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/issues/:id/interactions/:interactionId/cancel",
|
||||
validate(cancelIssueThreadInteractionSchema),
|
||||
|
||||
@@ -3948,6 +3948,8 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
|
||||
"interactionStatus",
|
||||
"continuationPolicy",
|
||||
"checkboxSelection",
|
||||
"itemVerdicts",
|
||||
"newlyResolvedItemIds",
|
||||
] as const;
|
||||
|
||||
function isInteractionResolutionWakePayload(payload: Record<string, unknown> | null | undefined) {
|
||||
|
||||
@@ -21,10 +21,14 @@ import type {
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestConfirmationTarget,
|
||||
RequestItemVerdictsInteraction,
|
||||
RequestItemVerdictsResult,
|
||||
RequestItemVerdictsResultItem,
|
||||
RejectIssueThreadInteraction,
|
||||
RespondIssueThreadInteraction,
|
||||
SuggestTasksInteraction,
|
||||
SuggestTasksResultCreatedTask,
|
||||
SubmitIssueThreadInteractionVerdicts,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
acceptIssueThreadInteractionSchema,
|
||||
@@ -37,8 +41,11 @@ import {
|
||||
requestCheckboxConfirmationResultSchema,
|
||||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResultSchema,
|
||||
requestItemVerdictsPayloadSchema,
|
||||
requestItemVerdictsResultSchema,
|
||||
suggestTasksPayloadSchema,
|
||||
suggestTasksResultSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
@@ -85,19 +92,32 @@ type RequestConfirmationLikeInteraction =
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction;
|
||||
|
||||
const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
|
||||
const TARGET_BOUND_INTERACTION_KINDS = [
|
||||
...REQUEST_CONFIRMATION_INTERACTION_KINDS,
|
||||
"request_item_verdicts",
|
||||
] as const;
|
||||
type TargetBoundInteractionKind = (typeof TARGET_BOUND_INTERACTION_KINDS)[number];
|
||||
type TargetBoundInteraction =
|
||||
| RequestConfirmationLikeInteraction
|
||||
| RequestItemVerdictsInteraction;
|
||||
|
||||
const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
|
||||
...TARGET_BOUND_INTERACTION_KINDS,
|
||||
"ask_user_questions",
|
||||
] as const;
|
||||
type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number];
|
||||
type UserCommentSupersedableInteraction =
|
||||
| RequestConfirmationLikeInteraction
|
||||
| TargetBoundInteraction
|
||||
| AskUserQuestionsInteraction;
|
||||
|
||||
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
|
||||
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
function isTargetBoundInteractionKind(kind: string): kind is TargetBoundInteractionKind {
|
||||
return (TARGET_BOUND_INTERACTION_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
function isUserCommentSupersedableKind(kind: string): kind is UserCommentSupersedableKind {
|
||||
return (USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
@@ -167,6 +187,13 @@ function hydrateInteraction(
|
||||
payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload),
|
||||
result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null,
|
||||
} satisfies RequestCheckboxConfirmationInteraction;
|
||||
case "request_item_verdicts":
|
||||
return {
|
||||
...base,
|
||||
kind: "request_item_verdicts",
|
||||
payload: requestItemVerdictsPayloadSchema.parse(row.payload),
|
||||
result: row.result ? requestItemVerdictsResultSchema.parse(row.result) : null,
|
||||
} satisfies RequestItemVerdictsInteraction;
|
||||
default:
|
||||
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
|
||||
}
|
||||
@@ -227,6 +254,14 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C
|
||||
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
|
||||
},
|
||||
};
|
||||
case "request_item_verdicts":
|
||||
return {
|
||||
...input,
|
||||
payload: {
|
||||
...input.payload,
|
||||
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return input;
|
||||
}
|
||||
@@ -243,6 +278,17 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (row.kind === "request_item_verdicts") {
|
||||
const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction;
|
||||
return {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
complete: false,
|
||||
items: interaction.result?.items ?? [],
|
||||
commentId,
|
||||
} satisfies RequestItemVerdictsResult;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
@@ -250,6 +296,28 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI
|
||||
} as const;
|
||||
}
|
||||
|
||||
function buildStaleTargetResult(
|
||||
row: IssueThreadInteractionRow,
|
||||
staleTarget: RequestConfirmationTarget | null,
|
||||
) {
|
||||
if (row.kind === "request_item_verdicts") {
|
||||
const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction;
|
||||
return {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
complete: false,
|
||||
items: interaction.result?.items ?? [],
|
||||
staleTarget,
|
||||
} satisfies RequestItemVerdictsResult;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function resolveActorKind(interaction: Pick<IssueThreadInteraction, "resolvedByAgentId" | "resolvedByUserId">) {
|
||||
if (interaction.resolvedByAgentId) return "agent";
|
||||
if (interaction.resolvedByUserId) return "user";
|
||||
@@ -263,10 +331,14 @@ function resolveCreatorKind(interaction: Pick<IssueThreadInteraction, "createdBy
|
||||
}
|
||||
|
||||
function deriveTargetType(interaction: IssueThreadInteraction) {
|
||||
if (interaction.kind !== "request_confirmation" && interaction.kind !== "request_checkbox_confirmation") {
|
||||
return "none";
|
||||
switch (interaction.kind) {
|
||||
case "request_confirmation":
|
||||
case "request_checkbox_confirmation":
|
||||
case "request_item_verdicts":
|
||||
return interaction.payload.target?.type ?? "none";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
return interaction.payload.target?.type ?? "none";
|
||||
}
|
||||
|
||||
function deriveResolutionReason(interaction: IssueThreadInteraction) {
|
||||
@@ -284,8 +356,17 @@ function deriveResolutionReason(interaction: IssueThreadInteraction) {
|
||||
if (interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation") {
|
||||
return interaction.result?.outcome ?? "expired";
|
||||
}
|
||||
if (interaction.kind === "request_item_verdicts") {
|
||||
return interaction.result?.outcome ?? "expired";
|
||||
}
|
||||
return "expired";
|
||||
}
|
||||
case "answered": {
|
||||
if (interaction.kind === "request_item_verdicts") {
|
||||
return interaction.result?.outcome ?? "answered";
|
||||
}
|
||||
return "answered";
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -315,6 +396,11 @@ function buildInteractionResolvedCounts(interaction: IssueThreadInteraction, arg
|
||||
questionCount: nonNegativeInteger(interaction.payload.questions.length),
|
||||
answeredQuestionCount: nonNegativeInteger(interaction.result?.answers?.length ?? 0),
|
||||
};
|
||||
case "request_item_verdicts":
|
||||
return {
|
||||
itemCount: nonNegativeInteger(interaction.payload.items.length),
|
||||
resolvedItemCount: nonNegativeInteger(interaction.result?.items?.length ?? 0),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -507,6 +593,68 @@ function resolveSelectedCheckboxConfirmationOptions(args: {
|
||||
.map((option) => option.id);
|
||||
}
|
||||
|
||||
function resolveRequestItemVerdictSubmissions(args: {
|
||||
interaction: RequestItemVerdictsInteraction;
|
||||
input: SubmitIssueThreadInteractionVerdicts;
|
||||
actor: InteractionActor;
|
||||
now: Date;
|
||||
}) {
|
||||
if (!args.actor.userId) {
|
||||
throw unprocessable("request_item_verdicts submissions require a user actor");
|
||||
}
|
||||
|
||||
const existingItems = args.interaction.result?.items ?? [];
|
||||
const existingById = new Map(existingItems.map((item) => [item.id, item] as const));
|
||||
const payloadItemIds = new Set(args.interaction.payload.items.map((item) => item.id));
|
||||
const enabledVerdicts = new Set(args.interaction.payload.verdicts ?? ["approve", "reject"]);
|
||||
const requireReasonOn = new Set(args.interaction.payload.requireReasonOn ?? ["reject"]);
|
||||
const newlyResolvedById = new Map<string, RequestItemVerdictsResultItem>();
|
||||
const newlyResolvedItemIds: string[] = [];
|
||||
|
||||
for (const submitted of args.input.verdicts) {
|
||||
if (!payloadItemIds.has(submitted.id)) {
|
||||
throw unprocessable(`Unknown item verdict id: ${submitted.id}`);
|
||||
}
|
||||
if (existingById.has(submitted.id)) {
|
||||
continue;
|
||||
}
|
||||
if (!enabledVerdicts.has(submitted.verdict)) {
|
||||
throw unprocessable(`Verdict ${submitted.verdict} is not enabled for this item verdict request`);
|
||||
}
|
||||
|
||||
const reason = submitted.reason?.trim() ?? "";
|
||||
if (requireReasonOn.has(submitted.verdict) && reason.length === 0) {
|
||||
throw unprocessable(`A reason is required when verdict is ${submitted.verdict}`);
|
||||
}
|
||||
|
||||
if (newlyResolvedById.has(submitted.id)) {
|
||||
continue;
|
||||
}
|
||||
newlyResolvedById.set(submitted.id, {
|
||||
id: submitted.id,
|
||||
verdict: submitted.verdict,
|
||||
...(reason ? { reason } : {}),
|
||||
resolvedByUserId: args.actor.userId,
|
||||
resolvedAt: args.now,
|
||||
});
|
||||
newlyResolvedItemIds.push(submitted.id);
|
||||
}
|
||||
|
||||
const nextById = new Map<string, RequestItemVerdictsResultItem>(existingItems.map((item) => [item.id, item]));
|
||||
for (const [id, item] of newlyResolvedById) {
|
||||
nextById.set(id, item);
|
||||
}
|
||||
const items = args.interaction.payload.items
|
||||
.map((item) => nextById.get(item.id))
|
||||
.filter((item): item is RequestItemVerdictsResultItem => Boolean(item));
|
||||
|
||||
return {
|
||||
items,
|
||||
complete: items.length === args.interaction.payload.items.length,
|
||||
newlyResolvedItemIds,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeQuestionAnswers(args: {
|
||||
questions: AskUserQuestionsInteraction["payload"]["questions"];
|
||||
answers: RespondIssueThreadInteraction["answers"];
|
||||
@@ -653,8 +801,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
|
||||
row: IssueThreadInteractionRow;
|
||||
actor: InteractionActor;
|
||||
}): Promise<IssueThreadInteraction | null> {
|
||||
if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null;
|
||||
const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction;
|
||||
if (!isTargetBoundInteractionKind(args.row.kind) || args.row.status !== "pending") return null;
|
||||
const interaction = hydrateInteraction(args.row) as TargetBoundInteraction;
|
||||
const target = interaction.payload.target ?? null;
|
||||
if (!target) return null;
|
||||
if (target.type !== "issue_document") return null;
|
||||
@@ -686,9 +834,7 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
|
||||
}
|
||||
: interaction.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget: target,
|
||||
...buildStaleTargetResult(args.row, target),
|
||||
},
|
||||
resolvedByAgentId: args.actor.agentId ?? null,
|
||||
resolvedByUserId: args.actor.userId ?? null,
|
||||
@@ -1002,7 +1148,11 @@ export function issueThreadInteractionService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.kind === "request_confirmation" || data.kind === "request_checkbox_confirmation") {
|
||||
if (
|
||||
data.kind === "request_confirmation"
|
||||
|| data.kind === "request_checkbox_confirmation"
|
||||
|| data.kind === "request_item_verdicts"
|
||||
) {
|
||||
await assertRequestConfirmationTargetIsCurrent(db, {
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
@@ -1274,6 +1424,98 @@ export function issueThreadInteractionService(db: Db) {
|
||||
}
|
||||
},
|
||||
|
||||
submitItemVerdicts: async (
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
input: SubmitIssueThreadInteractionVerdicts,
|
||||
actor: InteractionActor,
|
||||
): Promise<{ interaction: IssueThreadInteraction; newlyResolvedItemIds: string[] }> => {
|
||||
const data = submitIssueThreadInteractionVerdictsSchema.parse(input);
|
||||
const current = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
}
|
||||
if (current.kind !== "request_item_verdicts") {
|
||||
throw unprocessable("Only request_item_verdicts interactions can receive item verdicts");
|
||||
}
|
||||
|
||||
const interaction = hydrateInteraction(current) as RequestItemVerdictsInteraction;
|
||||
if (current.status !== "pending") {
|
||||
if (current.status === "answered") {
|
||||
const resolvedIds = new Set(interaction.result?.items.map((item) => item.id) ?? []);
|
||||
const payloadIds = new Set(interaction.payload.items.map((item) => item.id));
|
||||
for (const submitted of data.verdicts) {
|
||||
if (!payloadIds.has(submitted.id)) {
|
||||
throw unprocessable(`Unknown item verdict id: ${submitted.id}`);
|
||||
}
|
||||
if (!resolvedIds.has(submitted.id)) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
}
|
||||
}
|
||||
return { interaction, newlyResolvedItemIds: [] };
|
||||
}
|
||||
throw conflict("Interaction has already been resolved");
|
||||
}
|
||||
|
||||
const expired = await expireStaleRequestConfirmationTarget(db, {
|
||||
row: current,
|
||||
actor,
|
||||
});
|
||||
if (expired) {
|
||||
return { interaction: expired, newlyResolvedItemIds: [] };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const { items, complete, newlyResolvedItemIds } = resolveRequestItemVerdictSubmissions({
|
||||
interaction,
|
||||
input: data,
|
||||
actor,
|
||||
now,
|
||||
});
|
||||
if (newlyResolvedItemIds.length === 0) {
|
||||
return { interaction, newlyResolvedItemIds: [] };
|
||||
}
|
||||
|
||||
const result = {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete,
|
||||
items,
|
||||
} satisfies RequestItemVerdictsResult;
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: complete ? "answered" : "pending",
|
||||
result,
|
||||
resolvedByAgentId: complete ? actor.agentId ?? null : null,
|
||||
resolvedByUserId: complete ? actor.userId ?? null : null,
|
||||
resolvedAt: complete ? now : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, interactionId),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
}
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
const submitted = hydrateInteraction(updated);
|
||||
if (complete) {
|
||||
await emitInteractionResolvedTelemetry(db, submitted);
|
||||
}
|
||||
return { interaction: submitted, newlyResolvedItemIds };
|
||||
},
|
||||
|
||||
rejectSuggestedTasks: async (
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
@@ -1447,6 +1689,8 @@ export function issueThreadInteractionService(db: Db) {
|
||||
const confirmationRowIds = commentRows
|
||||
.filter((row) => isRequestConfirmationLikeKind(row.kind))
|
||||
.map((row) => row.id);
|
||||
const itemVerdictRows = commentRows
|
||||
.filter((row) => row.kind === "request_item_verdicts");
|
||||
|
||||
if (questionRowIds.length > 0) {
|
||||
const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions");
|
||||
@@ -1489,6 +1733,25 @@ export function issueThreadInteractionService(db: Db) {
|
||||
.returning();
|
||||
expired.push(...updatedRows.map(hydrateInteraction));
|
||||
}
|
||||
|
||||
for (const row of itemVerdictRows) {
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: buildSupersededByCommentResult(row, comment.id),
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: comment.authorUserId,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
if (updated) expired.push(hydrateInteraction(updated));
|
||||
}
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
@@ -1509,12 +1772,12 @@ export function issueThreadInteractionService(db: Db) {
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
|
||||
inArray(issueThreadInteractions.kind, [...TARGET_BOUND_INTERACTION_KINDS]),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
));
|
||||
|
||||
const staleRows = rows.filter((row) => {
|
||||
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
|
||||
const interaction = hydrateInteraction(row) as TargetBoundInteraction;
|
||||
const target = interaction.payload.target;
|
||||
if (!target || target.type !== "issue_document") return false;
|
||||
const targetIssueId = target.issueId ?? issue.id;
|
||||
@@ -1533,7 +1796,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
const now = new Date();
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
for (const row of staleRows) {
|
||||
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
|
||||
const interaction = hydrateInteraction(row) as TargetBoundInteraction;
|
||||
const target = interaction.payload.target ?? null;
|
||||
const currentTarget = buildIssueDocumentTargetFromDocument({
|
||||
issueId: issue.id,
|
||||
@@ -1549,11 +1812,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
target: currentTarget,
|
||||
}
|
||||
: interaction.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget: target,
|
||||
},
|
||||
result: buildStaleTargetResult(row, target),
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByUserId: actor.userId ?? null,
|
||||
resolvedAt: now,
|
||||
|
||||
@@ -200,20 +200,21 @@ POST /api/companies/{companyId}/approvals
|
||||
|
||||
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:
|
||||
Five 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`). |
|
||||
| `request_item_verdicts` | Board must approve/reject/defer individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. |
|
||||
| `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.
|
||||
- **Continuation policy.** `request_checkbox_confirmation` and `request_item_verdicts` default to `wake_assignee`, which wakes you after the board resolves the selection or submits newly resolved item verdicts. `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`, `request_checkbox_confirmation`, and `request_item_verdicts` 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.** Target-bound request 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.
|
||||
|
||||
@@ -257,6 +258,35 @@ When the board accepts, your wake delivers `result.selectedOptionIds` — the op
|
||||
|
||||
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**.
|
||||
|
||||
Create `request_item_verdicts` when each known item needs its own verdict:
|
||||
|
||||
```json
|
||||
POST /api/issues/{issueId}/interactions
|
||||
{
|
||||
"kind": "request_item_verdicts",
|
||||
"idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"prompt": "Review each generated artifact.",
|
||||
"items": [
|
||||
{ "id": "api", "label": "API route", "description": "Partial submit endpoint." },
|
||||
{ "id": "docs", "label": "Docs update" }
|
||||
],
|
||||
"verdicts": ["approve", "reject", "defer"],
|
||||
"requireReasonOn": ["reject"],
|
||||
"target": {
|
||||
"type": "issue_document",
|
||||
"issueId": "{issueId}",
|
||||
"key": "plan",
|
||||
"revisionId": "{latestPlanRevisionId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The board submits verdicts with `POST /api/issues/{issueId}/interactions/{interactionId}/verdicts`. Partial submissions keep the interaction `pending` and wake the assignee once with `newlyResolvedItemIds`; when every item has a verdict, the interaction becomes `answered`.
|
||||
|
||||
## Niche Workflow Pointers
|
||||
|
||||
Load `references/workflows.md` when the task matches one of these:
|
||||
|
||||
@@ -960,6 +960,116 @@ Best practice:
|
||||
- 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.
|
||||
|
||||
### Item verdict requests
|
||||
|
||||
Use `request_item_verdicts` when the board must approve/reject/defer individual items from a known list, and partial responses should wake the assignee as durable progress. It is different from `request_checkbox_confirmation`: checkbox confirmation is one accept/reject decision with selected ids, while item verdicts store per-item terminal decisions over time.
|
||||
|
||||
Create an item-verdict request:
|
||||
|
||||
```json
|
||||
POST /api/issues/{issueId}/interactions
|
||||
{
|
||||
"kind": "request_item_verdicts",
|
||||
"idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}",
|
||||
"title": "Review generated artifacts",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"prompt": "Review each generated artifact.",
|
||||
"detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.",
|
||||
"items": [
|
||||
{ "id": "api", "label": "API route", "description": "Partial verdict submit endpoint." },
|
||||
{ "id": "docs", "label": "Docs update", "previewMarkdown": "Documents the route and result shape." }
|
||||
],
|
||||
"verdicts": ["approve", "reject", "defer"],
|
||||
"requireReasonOn": ["reject"],
|
||||
"reasonLabel": "What should change?",
|
||||
"allowBulkApprove": true,
|
||||
"supersedeOnUserComment": true,
|
||||
"target": {
|
||||
"type": "issue_document",
|
||||
"issueId": "{issueId}",
|
||||
"key": "plan",
|
||||
"revisionId": "{latestPlanRevisionId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Payload field reference (`RequestItemVerdictsPayload`):
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
| ------------------------ | -------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `version` | `1` | required | Versioned for forward compatibility. |
|
||||
| `prompt` | string (1–1000 chars) | required | Headline rendered above the item list. |
|
||||
| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. |
|
||||
| `items` | `[{ id, label, description?, previewMarkdown?, href?, attachmentId? }]` | required, 1–200 entries | Item `id` and `label` are 1–120 chars. Item ids must be unique. `href` must be safe: root-relative, fragment, or http(s). |
|
||||
| `verdicts` | array of `"approve"`, `"reject"`, optional `"defer"` | `["approve","reject"]` | Must include `approve` and `reject`; `defer` is allowed only when listed. |
|
||||
| `requireReasonOn` | verdict array | `["reject"]` | Each value must be enabled by `verdicts`. Pending submissions with those verdicts require a non-empty `reason`. |
|
||||
| `reasonLabel` | string (1–160) \| `null` | `null` | Field label for the verdict reason. |
|
||||
| `allowBulkApprove` | boolean | `true` | UI hint for bulk-approve affordances. Server still validates each submitted item id. |
|
||||
| `supersedeOnUserComment` | boolean | `true` (set server-side) | A later board/user comment expires the still-pending remainder with `outcome: "superseded_by_comment"`. |
|
||||
| `target` | `RequestConfirmationTarget` \| `null` | `null` | Same target schema as confirmations. Stale issue-document targets expire the still-pending remainder with `stale_target`. |
|
||||
|
||||
Submit item verdicts (board action, requires board/user role; agents creating the interaction cannot submit verdicts):
|
||||
|
||||
```json
|
||||
POST /api/issues/{issueId}/interactions/{interactionId}/verdicts
|
||||
{
|
||||
"verdicts": [
|
||||
{ "id": "api", "verdict": "approve" },
|
||||
{ "id": "docs", "verdict": "reject", "reason": "Needs install instructions." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Server behavior:
|
||||
|
||||
- Unknown item ids return 422.
|
||||
- A verdict not listed in `payload.verdicts` returns 422.
|
||||
- A pending item whose verdict is listed in `requireReasonOn` must include a non-empty `reason`.
|
||||
- Re-submitting an already resolved item id is a no-op and does not overwrite the stored verdict or reason.
|
||||
- Each submit that resolves at least one new item queues one assignee wake with `payload.newlyResolvedItemIds` and `payload.itemVerdicts.newlyResolvedItemIds`. Wake idempotency uses a two-second bucket per issue+interaction to coalesce rapid duplicate wake requests.
|
||||
|
||||
Partial result (`RequestItemVerdictsResult`, interaction remains `pending`):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"outcome": "resolved",
|
||||
"complete": false,
|
||||
"items": [
|
||||
{
|
||||
"id": "docs",
|
||||
"verdict": "reject",
|
||||
"reason": "Needs install instructions.",
|
||||
"resolvedByUserId": "local-board",
|
||||
"resolvedAt": "2026-07-09T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Complete result (interaction becomes `answered`):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"outcome": "resolved",
|
||||
"complete": true,
|
||||
"items": [
|
||||
{ "id": "api", "verdict": "approve", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" },
|
||||
{ "id": "docs", "verdict": "reject", "reason": "Needs install instructions.", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Expiration results preserve already resolved items and omit undecided items:
|
||||
|
||||
- `superseded_by_comment` — `{ outcome: "superseded_by_comment", complete: false, items, commentId }`.
|
||||
- `stale_target` — `{ outcome: "stale_target", complete: false, items, staleTarget }`.
|
||||
- `cancelled` is reserved for future explicit cancellation flows.
|
||||
|
||||
### Checking approval status
|
||||
|
||||
```
|
||||
@@ -1069,10 +1179,11 @@ 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`, `request_checkbox_confirmation`) |
|
||||
| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) |
|
||||
| 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 |
|
||||
| POST | `/api/issues/:issueId/interactions/:interactionId/verdicts` | Submit partial item verdicts for `request_item_verdicts` |
|
||||
| GET | `/api/issues/:issueId/documents` | List issue documents |
|
||||
| GET | `/api/issues/:issueId/documents/:key` | Get issue document by key |
|
||||
| PUT | `/api/issues/:issueId/documents/:key` | Create or update issue document (send `baseRevisionId` when updating) |
|
||||
|
||||
Reference in new issue
Block a user