Add request item verdict interactions

This commit is contained in:
Dotta committed 2026-07-10 20:07:55 +00:00
1 parent 1e8ede4e1e
commit f5ab828a48
14 files changed
+1349 -27

No files matched your search

+2
View File
@@ -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",
+14
View File
@@ -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");
});
});
+6
View File
@@ -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,
+54 -3
View File
@@ -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;
+7
View File
@@ -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,
+149
View File
@@ -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";
+95
View File
@@ -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),
+2
View File
@@ -3948,6 +3948,8 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
"interactionStatus",
"continuationPolicy",
"checkboxSelection",
"itemVerdicts",
"newlyResolvedItemIds",
] as const;
function isInteractionResolutionWakePayload(payload: Record<string, unknown> | null | undefined) {
+278 -19
View File
@@ -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,
+34 -4
View File
@@ -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:
+112 -1
View File
@@ -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) |