import { describe, expect, it } from "vitest"; import { buildAnsweredQuestionsDeliveryText, buildIssueThreadInteractionSummary, buildSuggestedTaskTree, collectSuggestedTaskClientKeys, countSuggestedTaskNodes, getCheckboxConfirmationSelectedLabels, getItemVerdictProgress, getRequestConfirmationTargetHref, getQuestionAnswerLabels, interactionReplacesComposerSkip, isDegenerateAskUserQuestions, isSupersededByNewerSiblingInteraction, shouldHideInteractionCard, normalizeRequestConfirmationTargetHref, } from "./issue-thread-interactions"; import type { AskUserQuestionsInteraction, AskUserQuestionsQuestion, IssueThreadInteraction, RequestItemVerdictsInteraction, } from "./issue-thread-interactions"; // The open default every interaction now inherits when its creator omits // `resolverPolicy` (PAP-17277 contract). const resolverPolicyFields = { resolverPolicy: "anyone", requestedResolverPolicy: "anyone", effectiveResolverPolicy: "anyone", resolverPolicyProvenance: "inherited", effectiveResolverPolicySource: "requested", legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" }, } as const; describe("buildSuggestedTaskTree", () => { it("preserves parent-child relationships from client keys", () => { const roots = buildSuggestedTaskTree([ { clientKey: "root", title: "Root", }, { clientKey: "child", parentClientKey: "root", title: "Child", }, { clientKey: "grandchild", parentClientKey: "child", title: "Grandchild", }, ]); expect(roots).toHaveLength(1); expect(roots[0]?.task.clientKey).toBe("root"); expect(roots[0]?.children[0]?.task.clientKey).toBe("child"); expect(countSuggestedTaskNodes(roots[0]!)).toBe(3); expect(collectSuggestedTaskClientKeys(roots[0]!)).toEqual(["root", "child", "grandchild"]); }); }); describe("issue thread interaction helpers", () => { it("replaces composer Skip only when the durable form has its own alternative path", () => { const base = { id: "interaction-1", companyId: "company-1", issueId: "issue-1", status: "pending", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", result: null, } as const; expect(interactionReplacesComposerSkip({ ...base, kind: "request_confirmation", payload: { version: 1, prompt: "Proceed?" }, } as IssueThreadInteraction)).toBe(true); expect(interactionReplacesComposerSkip({ ...base, kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Select", options: [] }, } as IssueThreadInteraction)).toBe(true); expect(interactionReplacesComposerSkip({ ...base, kind: "suggest_tasks", payload: { version: 1, tasks: [] }, } as IssueThreadInteraction)).toBe(true); expect(interactionReplacesComposerSkip({ ...base, kind: "ask_user_questions", payload: { version: 1, questions: [] }, } as IssueThreadInteraction)).toBe(false); expect(interactionReplacesComposerSkip({ ...base, kind: "request_item_verdicts", payload: { version: 1, prompt: "Review", items: [] }, } as IssueThreadInteraction)).toBe(false); }); it("formats successor-turn answer delivery with canonical prompts and labels", () => { const interaction = { id: "interaction-answers", companyId: "company-1", issueId: "issue-1", kind: "ask_user_questions", status: "answered", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:01:00.000Z", payload: { version: 1, questions: [ { id: "purpose", prompt: "What is it for?", selectionMode: "single", options: [] }, { id: "runtime", prompt: "Which runtime?", selectionMode: "single", options: [{ id: "node", label: "Node.js" }] }, { id: "features", prompt: "Which features?", selectionMode: "multi", options: [{ id: "logs", label: "Request logs" }] }, ], questionSet: { schema: "paperclip.question_set.v1", questions: [ { id: "purpose", header: "Purpose", prompt: "What is it for?", required: true, answerMode: "text" }, { id: "runtime", header: "Runtime", prompt: "Which runtime?", required: true, answerMode: "single_select", options: [{ id: "node", label: "Node.js" }] }, { id: "features", header: "Features", prompt: "Which features?", required: false, answerMode: "multi_select", options: [{ id: "logs", label: "Request logs" }], customAnswer: { enabled: true, label: "Other" } }, ], }, }, result: { version: 1, answers: [ { questionId: "purpose", optionIds: [], otherText: "Internal API" }, { questionId: "runtime", optionIds: ["node"] }, { questionId: "features", optionIds: ["logs"], otherText: "Metrics" }, ], }, } satisfies AskUserQuestionsInteraction; expect(buildAnsweredQuestionsDeliveryText(interaction)).toBe([ "Answered questions", "", "- Purpose — What is it for?: Internal API", "- Runtime — Which runtime?: Node.js", "- Features — Which features?: Request logs, Metrics", ].join("\n")); }); it("summarizes task and question interactions", () => { expect(buildIssueThreadInteractionSummary({ id: "interaction-1", companyId: "company-1", issueId: "issue-1", kind: "suggest_tasks", status: "pending", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1, tasks: [ { clientKey: "task-1", title: "One" }, { clientKey: "task-2", title: "Two" }, ], }, })).toBe("Suggested 2 tasks"); expect(buildIssueThreadInteractionSummary({ id: "interaction-accepted", companyId: "company-1", issueId: "issue-1", kind: "suggest_tasks", status: "accepted", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1, tasks: [ { clientKey: "task-1", title: "One" }, { clientKey: "task-2", title: "Two" }, ], }, result: { version: 1, createdTasks: [{ clientKey: "task-1", issueId: "child-1" }], skippedClientKeys: ["task-2"], }, })).toBe("Accepted 1 of 2 tasks"); expect(buildIssueThreadInteractionSummary({ id: "interaction-2", companyId: "company-1", issueId: "issue-1", kind: "ask_user_questions", status: "pending", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1, questions: [ { id: "question-1", prompt: "Pick one", selectionMode: "single", options: [{ id: "option-1", label: "Option 1" }], }, ], }, })).toBe("Asked 1 question"); expect(buildIssueThreadInteractionSummary({ id: "interaction-answered", companyId: "company-1", issueId: "issue-1", kind: "ask_user_questions", status: "answered", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1, questions: [ { id: "question-1", prompt: "Pick one", selectionMode: "single", options: [{ id: "option-1", label: "Option 1" }], }, ], }, result: { version: 1, answers: [{ questionId: "question-1", optionIds: ["option-1"] }], }, })).toBe("Answered 1 question"); expect(buildIssueThreadInteractionSummary({ id: "interaction-expired", companyId: "company-1", issueId: "issue-1", kind: "ask_user_questions", status: "expired", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:05:00.000Z", payload: { version: 1, questions: [ { id: "question-1", prompt: "Pick one", selectionMode: "single", options: [{ id: "option-1", label: "Option 1" }], }, ], }, result: { version: 1, answers: [], expirationReason: "superseded_by_comment", commentId: "11111111-1111-4111-8111-111111111111", }, })).toBe("Question expired after comment"); }); it("summarizes checkbox confirmation interactions by count", () => { const base = { id: "interaction-checkbox", companyId: "company-1", issueId: "issue-1", kind: "request_checkbox_confirmation" as const, continuationPolicy: "wake_assignee" as const, ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1 as const, prompt: "Pick items", options: [ { id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }, ], }, }; expect(buildIssueThreadInteractionSummary({ ...base, status: "pending" })) .toBe("Requested a selection from 3 options"); expect(buildIssueThreadInteractionSummary({ ...base, status: "accepted", result: { version: 1, outcome: "accepted", selectedOptionIds: ["a", "c"] }, })).toBe("Confirmed 2 of 3 options"); expect(buildIssueThreadInteractionSummary({ ...base, status: "accepted", result: { version: 1, outcome: "accepted", selectedOptionIds: [] }, })).toBe("Confirmed with no options selected"); expect(buildIssueThreadInteractionSummary({ ...base, status: "expired", result: { version: 1, outcome: "stale_target" }, })).toBe("Selection expired after target changed"); }); it("uses a confirmation's explicit rejection action in its receipt", () => { const base = { id: "interaction-confirmation", companyId: "company-1", issueId: "issue-1", kind: "request_confirmation" as const, status: "rejected" as const, continuationPolicy: "wake_assignee" as const, ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:01:00.000Z", result: { version: 1 as const, outcome: "rejected" as const }, }; expect(buildIssueThreadInteractionSummary({ ...base, payload: { version: 1 as const, prompt: "Is this task ready to complete?", rejectLabel: "Continue work", }, })).toBe("Selected “Continue work”"); expect(buildIssueThreadInteractionSummary({ ...base, payload: { version: 1 as const, prompt: "Proceed?", }, })).toBe("Declined request"); }); it("maps selected checkbox option ids back to labels", () => { const labels = getCheckboxConfirmationSelectedLabels({ payload: { version: 1, prompt: "Pick items", options: [ { id: "a", label: "Alpha" }, { id: "b", label: "Bravo" }, { id: "c", label: "Charlie" }, ], }, result: { version: 1, outcome: "accepted", selectedOptionIds: ["c", "a", "missing"] }, }); expect(labels).toEqual(["Charlie", "Alpha"]); }); it("allows only safe confirmation target hrefs for rendering", () => { for (const href of [ "https://example.com/checklist", "http://example.com/checklist", "/PAP/issues/PAP-123#document-plan", "#document-plan", ]) { expect(normalizeRequestConfirmationTargetHref(href)).toBe(href); } for (const href of [ "file:///tmp/x", "mailto:user@example.com", "slack://channel?id=1", "vscode://file/tmp/x", "ftp://example.com/file", "//evil.example/path", ]) { expect(normalizeRequestConfirmationTargetHref(href)).toBeNull(); } }); it("does not return unsafe custom target hrefs from accepted payloads", () => { expect(getRequestConfirmationTargetHref({ issueId: "issue-1", target: { type: "custom", key: "unsafe-target", label: "Unsafe target", href: "file:///tmp/x", }, })).toBeNull(); expect(getRequestConfirmationTargetHref({ issueId: "issue-1", target: { type: "issue_document", issueId: "issue-2", key: "plan", revisionId: "11111111-1111-4111-8111-111111111111", href: "slack://channel?id=1", }, })).toBe("/issues/issue-2#document-plan"); }); it("maps stored option ids back to labels for answered summaries", () => { const labels = getQuestionAnswerLabels({ question: { id: "question-1", prompt: "Pick options", selectionMode: "multi", options: [ { id: "option-1", label: "Option 1" }, { id: "option-2", label: "Option 2" }, ], }, answers: [ { questionId: "question-1", optionIds: ["option-2", "option-1"], otherText: "A written answer", }, ], }); expect(labels).toEqual(["Option 2", "Option 1", "Other: A written answer"]); }); }); describe("per-item verdict helpers", () => { function verdictInteraction( overrides: Partial = {}, ): RequestItemVerdictsInteraction { return { id: "interaction-verdicts", companyId: "company-1", issueId: "issue-1", kind: "request_item_verdicts", status: "pending", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1, prompt: "Review the posts.", items: [ { id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }, ], verdicts: ["approve", "reject"], requireReasonOn: ["reject"], }, ...overrides, } as RequestItemVerdictsInteraction; } it("counts decided items and lists still-pending ids in payload order", () => { const progress = getItemVerdictProgress({ payload: verdictInteraction().payload, result: { version: 1, outcome: "resolved", complete: false, items: [ { id: "a", verdict: "approve", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" }, { id: "c", verdict: "reject", reason: "no", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" }, ], }, }); expect(progress).toMatchObject({ total: 3, decided: 2, approved: 1, rejected: 1, deferred: 0 }); expect(progress.pendingItemIds).toEqual(["b"]); }); it("summarizes pending, complete, and superseded verdict cards", () => { expect(buildIssueThreadInteractionSummary(verdictInteraction())).toBe("0 of 3 decided"); expect(buildIssueThreadInteractionSummary(verdictInteraction({ status: "answered", result: { version: 1, outcome: "resolved", complete: true, items: [ { id: "a", verdict: "approve", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" }, { id: "b", verdict: "approve", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" }, { id: "c", verdict: "reject", reason: "no", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" }, ], }, }))).toBe("3 decided · 2 approved · 1 rejected"); expect(buildIssueThreadInteractionSummary(verdictInteraction({ status: "expired", result: { version: 1, outcome: "superseded_by_comment", complete: false, items: [], }, }))).toBe("Verdicts expired after comment"); }); }); describe("isDegenerateAskUserQuestions", () => { function askInteraction( questions: AskUserQuestionsQuestion[], ): AskUserQuestionsInteraction { return { id: "interaction-ask", companyId: "company-1", issueId: "issue-1", kind: "ask_user_questions", status: "pending", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1, questions }, } as AskUserQuestionsInteraction; } it("flags a card with zero questions", () => { expect(isDegenerateAskUserQuestions(askInteraction([]))).toBe(true); }); it("flags a question with a blank / whitespace-only prompt", () => { expect(isDegenerateAskUserQuestions(askInteraction([ { id: "q1", prompt: " ", selectionMode: "single", options: [ { id: "a", label: "A" }, { id: "b", label: "B" }, ], }, ]))).toBe(true); }); it("keeps a single fixed-option question: it is answerable (select + submit)", () => { // A lone fixed option is still resolvable — the user selects it and submits — // so it must render. Hiding it would strand a pending interaction the // assignee is waiting on. expect(isDegenerateAskUserQuestions(askInteraction([ { id: "q1", prompt: "Test", selectionMode: "single", options: [{ id: "a", label: "A" }], }, ]))).toBe(false); }); it("flags a question with no options at all", () => { expect(isDegenerateAskUserQuestions(askInteraction([ { id: "q1", prompt: "Anything?", selectionMode: "single", options: [] }, ]))).toBe(true); }); it("keeps a legitimate yes/no question (2 options)", () => { expect(isDegenerateAskUserQuestions(askInteraction([ { id: "q1", prompt: "Ship it?", selectionMode: "single", options: [ { id: "yes", label: "Yes" }, { id: "no", label: "No" }, ], }, ]))).toBe(false); }); it("keeps a multi-select question (>= 2 options)", () => { expect(isDegenerateAskUserQuestions(askInteraction([ { id: "q1", prompt: "Pick platforms", selectionMode: "multi", options: [ { id: "web", label: "Web" }, { id: "ios", label: "iOS" }, { id: "android", label: "Android" }, ], }, ]))).toBe(false); }); it("keeps a question whose only choice is a first-class free-text option (PAP-419)", () => { expect(isDegenerateAskUserQuestions(askInteraction([ { id: "q1", prompt: "Describe your goal", selectionMode: "single", options: [{ id: "other", label: "I'll describe it", freeText: true }], }, ]))).toBe(false); }); it("is degenerate only when EVERY question is degenerate", () => { // One real question keeps the whole card, even next to a degenerate one // (here: a question with no options and no free-text). expect(isDegenerateAskUserQuestions(askInteraction([ { id: "q1", prompt: "Anything?", selectionMode: "single", options: [] }, { id: "q2", prompt: "Ship it?", selectionMode: "single", options: [ { id: "yes", label: "Yes" }, { id: "no", label: "No" }, ], }, ]))).toBe(false); }); it("ignores non-ask_user_questions interactions", () => { const confirmation = { id: "interaction-confirm", companyId: "company-1", issueId: "issue-1", kind: "request_confirmation", status: "pending", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1 }, } as unknown as IssueThreadInteraction; expect(isDegenerateAskUserQuestions(confirmation)).toBe(false); }); }); describe("isSupersededByNewerSiblingInteraction", () => { function askInteraction(overrides: Partial): AskUserQuestionsInteraction { return { id: "interaction-ask", companyId: "company-1", issueId: "issue-1", kind: "ask_user_questions", status: "expired", continuationPolicy: "wake_assignee", ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { version: 1, questions: [{ id: "q", prompt: "t", selectionMode: "single", options: [{ id: "L", label: "L" }] }], }, ...overrides, } as AskUserQuestionsInteraction; } it("hides an expired card superseded by a newer sibling question", () => { const interaction = askInteraction({ status: "expired", result: { version: 1, answers: [], expirationReason: "superseded_by_newer_interaction", supersededByInteractionId: "interaction-newer", summaryMarkdown: null, }, }); expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(true); expect(shouldHideInteractionCard(interaction)).toBe(true); }); it("never hides a still-pending card (would strand the assignee)", () => { // Guards the PAP-424 / 00b136f45 invariant: a pending question must always // render even if the result somehow already names a superseding sibling. const interaction = askInteraction({ status: "pending", result: { version: 1, answers: [], expirationReason: "superseded_by_newer_interaction", supersededByInteractionId: "interaction-newer", summaryMarkdown: null, }, }); expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(false); expect(shouldHideInteractionCard(interaction)).toBe(false); }); it("keeps the stale notice for a comment-superseded card (does not hide it)", () => { const interaction = askInteraction({ status: "expired", result: { version: 1, answers: [], expirationReason: "superseded_by_comment", commentId: "11111111-1111-1111-1111-111111111111", summaryMarkdown: null, }, }); expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(false); expect(shouldHideInteractionCard(interaction)).toBe(false); }); it("still hides a degenerate card through the combined predicate", () => { const degenerate = askInteraction({ status: "pending", result: null, payload: { version: 1, questions: [] }, }); expect(isSupersededByNewerSiblingInteraction(degenerate)).toBe(false); expect(shouldHideInteractionCard(degenerate)).toBe(true); }); });