import { describe, expect, it } from "vitest"; import { ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES, ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES, legacyIssueThreadInteractionResolverPolicyAlias, normalizeIssueThreadInteractionResolverPolicy, } from "./constants.js"; import { acceptIssueThreadInteractionSchema, askUserQuestionsResultSchema, createIssueThreadInteractionSchema, paperclipQuestionSetPayloadSchema, requestConfirmationPayloadSchema, requestConfirmationResultSchema, requestItemVerdictsResultSchema, submitIssueThreadInteractionVerdictsSchema, } from "./validators/issue.js"; describe("issue thread interaction schemas", () => { it("defines canonical resolver policies and normalizes compatibility aliases", () => { expect(ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES).toEqual([ "anyone", "not_creator", "human_only", ]); expect(ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES).toEqual([ "board_or_agents", "board_only", ]); expect(normalizeIssueThreadInteractionResolverPolicy("board_or_agents")).toBe("anyone"); expect(normalizeIssueThreadInteractionResolverPolicy("board_only")).toBe("human_only"); expect(normalizeIssueThreadInteractionResolverPolicy("not_creator")).toBe("not_creator"); expect(legacyIssueThreadInteractionResolverPolicyAlias("anyone")).toBe("board_or_agents"); expect(legacyIssueThreadInteractionResolverPolicyAlias("not_creator")).toBeNull(); }); it.each(["anyone", "not_creator", "human_only", "board_or_agents", "board_only"] as const)( "accepts resolver policy input %s", (resolverPolicy) => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", resolverPolicy, payload: { version: 1, prompt: "Proceed?" }, }); expect(parsed.resolverPolicy).toBe(resolverPolicy); }, ); it("parses request_confirmation payloads with default no-wake continuation", () => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", payload: { version: 1, prompt: "Apply this plan?", acceptLabel: "Apply", rejectLabel: "Revise", rejectRequiresReason: true, rejectReasonLabel: "What needs to change?", declineReasonPlaceholder: "Optional: tell the agent what you'd change.", detailsMarkdown: "The current plan document will be accepted as-is.", supersedeOnUserComment: true, }, }); expect(parsed).toMatchObject({ kind: "request_confirmation", continuationPolicy: "none", payload: { prompt: "Apply this plan?", acceptLabel: "Apply", rejectLabel: "Revise", rejectRequiresReason: true, rejectReasonLabel: "What needs to change?", allowDeclineReason: true, declineReasonPlaceholder: "Optional: tell the agent what you'd change.", supersedeOnUserComment: true, }, }); }); it("round-trips versioned tool action payload and lifecycle metadata", () => { const payload = requestConfirmationPayloadSchema.parse({ version: 1, prompt: "Approve send_email?", toolAction: { version: 1, actionRequestId: "11111111-1111-4111-8111-111111111111", invocationId: "22222222-2222-4222-8222-222222222222", toolName: "send_email", toolDisplayName: "Send email", connectionId: "33333333-3333-4333-8333-333333333333", applicationId: "44444444-4444-4444-8444-444444444444", appDisplayName: "Gmail", risk: "write", previewMarkdown: "Send an email to the reviewed recipient.", argumentsSummaryJson: '{"to":"recipient@example.com"}', argumentsHash: "reviewed-arguments-hash", expiresAt: "2026-07-12T16:00:00.000Z", }, }); const result = requestConfirmationResultSchema.parse({ version: 1, outcome: "accepted", toolAction: { version: 1, status: "executed", errorCode: null, errorMessage: null, updatedAt: "2026-07-12T15:05:00.000Z", }, }); expect(payload.toolAction).toMatchObject({ version: 1, toolDisplayName: "Send email", risk: "write", argumentsHash: "reviewed-arguments-hash", }); expect(result.toolAction).toMatchObject({ version: 1, status: "executed" }); expect(requestConfirmationPayloadSchema.parse({ version: 1, prompt: "Legacy confirmation?" }).toolAction) .toBeUndefined(); }); it("parses superseded confirmation results with a replacement pointer", () => { const result = requestConfirmationResultSchema.parse({ version: 1, outcome: "superseded_by_newer_request", supersededByInteractionId: "11111111-1111-4111-8111-111111111111", }); expect(result).toEqual({ version: 1, outcome: "superseded_by_newer_request", supersededByInteractionId: "11111111-1111-4111-8111-111111111111", }); }); it("accepts run-attributed agent item verdict results and rejects missing runs", () => { const result = { version: 1, outcome: "resolved", complete: true, items: [{ id: "api", verdict: "approve", resolvedByAgentId: "11111111-1111-4111-8111-111111111111", resolvedByRunId: "22222222-2222-4222-8222-222222222222", resolvedAt: "2026-08-14T12:00:00.000Z", }], }; expect(requestItemVerdictsResultSchema.parse(result)).toMatchObject(result); expect(() => requestItemVerdictsResultSchema.parse({ ...result, items: [{ ...result.items[0], resolvedByRunId: undefined }], })).toThrow("resolvedByRunId is required for an agent resolver"); }); it("accepts issue document targets for request_confirmation interactions", () => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, prompt: "Accept the latest plan revision?", allowDeclineReason: false, target: { type: "issue_document", issueId: "11111111-1111-4111-8111-111111111111", documentId: "22222222-2222-4222-8222-222222222222", key: "plan", revisionId: "33333333-3333-4333-8333-333333333333", revisionNumber: 2, label: "Plan v2", href: "/issues/PAP-123#document-plan", }, }, }); expect(parsed.kind).toBe("request_confirmation"); if (parsed.kind !== "request_confirmation") return; expect(parsed.payload.target).toMatchObject({ type: "issue_document", key: "plan", revisionNumber: 2, label: "Plan v2", href: "/issues/PAP-123#document-plan", }); }); it("accepts custom targets for request_confirmation interactions", () => { for (const href of [ "https://example.com/checklist", "http://example.com/checklist", "/PAP/issues/PAP-123#document-plan", "#document-plan", ]) { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", payload: { version: 1, prompt: "Proceed with the external checklist?", target: { type: "custom", key: "external-checklist", revisionId: "checklist-v1", revisionNumber: 1, label: "Checklist v1", href, }, }, }); expect(parsed.kind).toBe("request_confirmation"); if (parsed.kind !== "request_confirmation") return; expect(parsed.payload.target).toMatchObject({ type: "custom", key: "external-checklist", label: "Checklist v1", href, }); } }); it("parses ask_user_questions supersede flags and expired results", () => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "ask_user_questions", payload: { version: 1, title: "Choose scope", supersedeOnUserComment: false, questions: [ { id: "scope", prompt: "Which scope should I use?", selectionMode: "single", options: [{ id: "small", label: "Small" }], }, ], }, }); expect(parsed).toMatchObject({ kind: "ask_user_questions", continuationPolicy: "wake_assignee", payload: { supersedeOnUserComment: false, }, }); expect(askUserQuestionsResultSchema.parse({ version: 1, answers: [], expirationReason: "superseded_by_comment", commentId: "11111111-1111-4111-8111-111111111111", summaryMarkdown: null, })).toMatchObject({ expirationReason: "superseded_by_comment", commentId: "11111111-1111-4111-8111-111111111111", }); }); it("retains canonical runner question sets without narrowing their public bounds", () => { const questionSet = { schema: "paperclip.question_set.v1" as const, title: "Runner input", questions: [{ id: "deployment-color", prompt: "Which deployment color should the runner use?", required: true, answerMode: "single_select" as const, options: [{ id: "blue", label: "Blue" }], }], }; const parsed = createIssueThreadInteractionSchema.parse({ kind: "ask_user_questions", continuationPolicy: "none", resolverPolicy: "human_only", payload: { version: 1, questions: [{ id: "deployment-color", prompt: "Which deployment color should the runner use?", selectionMode: "single", allowOther: false, options: [{ id: "blue", label: "Blue" }], }], questionSet, }, }); expect(parsed.kind).toBe("ask_user_questions"); if (parsed.kind !== "ask_user_questions") return; expect(parsed.payload.questionSet).toEqual(questionSet); expect(() => paperclipQuestionSetPayloadSchema.parse({ ...questionSet, questions: [{ ...questionSet.questions[0], answerMode: "text", options: questionSet.questions[0].options }], })).toThrow("text questions cannot define options"); }); it("rejects unsafe request_confirmation target hrefs", () => { const base = { kind: "request_confirmation", payload: { version: 1, prompt: "Proceed?", target: { type: "custom", key: "external-checklist", revisionId: "checklist-v1", label: "Checklist v1", }, }, } as const; for (const href of [ "javascript:alert(1)", "data:text/html,hi", "//evil.example/path", "file:///tmp/x", "mailto:user@example.com", "slack://channel?id=1", "vscode://file/tmp/x", "ftp://example.com/file", ]) { expect(() => createIssueThreadInteractionSchema.parse({ ...base, payload: { ...base.payload, target: { ...base.payload.target, href, }, }, })).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL"); } }); it("parses request_checkbox_confirmation payloads with checkbox defaults", () => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Which items should be archived?", options: [ { id: "item-1", label: "Draft report" }, { id: "item-2", label: "Old screenshot", description: "Captured during QA." }, ], defaultSelectedOptionIds: ["item-2"], minSelected: 0, maxSelected: 2, acceptLabel: "Archive selected", rejectRequiresReason: true, target: { type: "issue_document", key: "plan", revisionId: "33333333-3333-4333-8333-333333333333", revisionNumber: 2, }, }, }); expect(parsed).toMatchObject({ kind: "request_checkbox_confirmation", continuationPolicy: "wake_assignee", payload: { allowDeclineReason: true, defaultSelectedOptionIds: ["item-2"], minSelected: 0, maxSelected: 2, }, }); }); it("rejects invalid request_checkbox_confirmation option references and bounds", () => { const base = { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Which items should be archived?", options: [ { id: "item-1", label: "Draft report" }, { id: "item-2", label: "Old screenshot" }, ], }, } as const; expect(() => createIssueThreadInteractionSchema.parse({ ...base, payload: { ...base.payload, options: [ { id: "item-1", label: "Draft report" }, { id: "item-1", label: "Duplicate" }, ], }, })).toThrow("Option ids must be unique within one checkbox confirmation"); expect(() => createIssueThreadInteractionSchema.parse({ ...base, payload: { ...base.payload, defaultSelectedOptionIds: ["missing"], }, })).toThrow("defaultSelectedOptionIds must reference existing option ids"); expect(() => createIssueThreadInteractionSchema.parse({ ...base, payload: { ...base.payload, defaultSelectedOptionIds: ["item-1"], minSelected: 2, }, })).toThrow("defaultSelectedOptionIds must satisfy minSelected"); expect(() => createIssueThreadInteractionSchema.parse({ ...base, payload: { ...base.payload, minSelected: 2, maxSelected: 1, }, })).toThrow("maxSelected must be greater than or equal to minSelected"); }); it("rejects unsafe request_checkbox_confirmation target hrefs", () => { const base = { kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Which items should be archived?", options: [{ id: "item-1", label: "Draft report" }], target: { type: "custom", key: "external-checklist", revisionId: "checklist-v1", label: "Checklist v1", }, }, } as const; for (const href of ["file:///tmp/x", "slack://channel?id=1", "vscode://file/tmp/x"]) { expect(() => createIssueThreadInteractionSchema.parse({ ...base, payload: { ...base.payload, target: { ...base.payload.target, href, }, }, })).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL"); } }); it("accepts empty checkbox selections and rejects duplicate selected option ids", () => { expect(acceptIssueThreadInteractionSchema.parse({ selectedOptionIds: [] })).toEqual({ selectedOptionIds: [], }); expect(() => acceptIssueThreadInteractionSchema.parse({ selectedOptionIds: ["item-1", "item-1"], })).toThrow("selectedOptionIds must be unique"); }); 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"); }); });