From f5ab828a489767e371a362ab011dbdb250da5acf Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 9 Jul 2026 15:53:08 +0000 Subject: [PATCH] Add request item verdict interactions --- packages/shared/src/constants.ts | 2 + packages/shared/src/index.ts | 14 + .../src/issue-thread-interactions.test.ts | 108 +++++ packages/shared/src/types/index.ts | 6 + packages/shared/src/types/issue.ts | 57 ++- packages/shared/src/validators/index.ts | 7 + packages/shared/src/validators/issue.ts | 149 +++++++ .../issue-thread-interaction-routes.test.ts | 105 +++++ .../issue-thread-interactions-service.test.ts | 383 ++++++++++++++++++ server/src/routes/issues.ts | 95 +++++ server/src/services/heartbeat.ts | 2 + .../src/services/issue-thread-interactions.ts | 297 +++++++++++++- skills/paperclip/SKILL.md | 38 +- skills/paperclip/references/api-reference.md | 113 +++++- 14 files changed, 1349 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 587d78d3cbf..801019a47a9 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e78fccfcbf1..d4ef35a9b4d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index fea5c7e5924..5251dc0360e 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -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"); + }); }); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c336dd262ab..d312edc0244 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index f651e482df2..d9c3324a47c 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 8b14b7e62c7..29a12a77dbd 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index a89116a38bb..0726a0cf752 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -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(); + 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(); + 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(); + 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(); + 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; @@ -989,6 +1115,29 @@ export const respondIssueThreadInteractionSchema = z.object({ }); export type RespondIssueThreadInteraction = z.infer; +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(); + 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; + export const linkIssueApprovalSchema = z.object({ approvalId: z.string().uuid(), }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index c0ab044906d..fb08b98f1c1 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -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(); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 9463c734e3d..8a957a6b4e6 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -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"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6dc7ab4e3f5..391c5a25b33 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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; 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), diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 67bad293841..4c266e6640f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3948,6 +3948,8 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ "interactionStatus", "continuationPolicy", "checkboxSelection", + "itemVerdicts", + "newlyResolvedItemIds", ] as const; function isInteractionResolutionWakePayload(payload: Record | null | undefined) { diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 62550db0dd9..b9538de9a6e 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -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) { if (interaction.resolvedByAgentId) return "agent"; if (interaction.resolvedByUserId) return "user"; @@ -263,10 +331,14 @@ function resolveCreatorKind(interaction: Pick 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(); + 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(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 { - 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, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index bb7f7463413..bc26b123e91 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -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: diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 57ed100deaa..1b26b59aef1 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -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) |