mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, where operators need a reliable way to find and act on work awaiting their input. > - The attention and issue-thread interaction subsystems expose those decision points across server APIs and the board UI. > - The previous navigation and interaction presentation left these actions fragmented and did not offer a controlled rollout for the Decisions surface. > - This branch adds the attention feed, richer interaction cards, grouping, dismiss/snooze behavior, and a gated Decisions sidebar entry. > - It also keeps experimental settings and API contracts synchronized, with an idempotent migration for the new dismissal state. > - This pull request delivers the complete, tested attention/Decisions experience as one reviewable unit. ## Linked Issues or Issue Description - Adds an operator-focused attention queue and Decisions experience: grouped decision cards, semantic interaction actions, dismiss/snooze handling, resilient interaction states, and an experimental flag to control the Decisions navigation entry. ## Feature Context ### Problem or Motivation Operators currently have to hunt across approvals, interactions, failed runs, and budget alerts to find decisions that need their action. ### Proposed Solution Provide a gated Decisions attention queue that groups actionable items, supports direct resolution, and preserves operator control through dismiss and snooze actions. ### Alternatives Considered Keep separate, source-specific views only; this leaves cross-cutting operator decisions fragmented and harder to prioritize. ### Roadmap Alignment This improves the V1 control-plane operator workflow by making pending governed actions discoverable in one company-scoped surface. ## What Changed - Added server attention-feed services, routes, interaction handling, dismiss/snooze support, and an idempotent `0145` inbox-dismissal migration. - Added shared attention, inbox-dismissal, and experimental-settings contracts. - Added Decisions/attention UI, interaction-card states, sidebar badge/navigation integration, grouping, keyboard support, and Storybook coverage. - Added tests for attention behavior, thread interactions, settings normalization, dismissals, and API behavior. - Removed generated screenshots from the final PR diff and rebased the branch onto current `master`. ## Verification - `pnpm check:token-gates` — passed. - `pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/attention-service.test.ts server/src/__tests__/inbox-dismissals.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts ui/src/lib/attention.test.ts ui/src/components/AttentionQueueRow.test.tsx ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx` — passed: 158 tests across 9 focused files. - GitHub Actions for `ad636f560`: build and typecheck/release-registry have passed; remaining general-server and Greptile checks are in progress. ## Risks - Moderate: this is a cross-layer attention/interaction feature with a new migration and navigation behavior. - The `enableDecisions` experimental setting defaults to off, limiting rollout impact. - Existing dismissal data is backfilled to `dismiss`; the migration is idempotent and uses guarded constraint creation. > ROADMAP.md was checked; no duplicate planned core feature was identified. Related open pull requests were searched before opening this PR. ## Model Used - OpenAI GPT-5.5 via Codex CLI, with tool use and local code execution. Context-window size unavailable in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My public PR branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally; focused tests pass and the remaining unrelated AWS test failure is documented above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
418 lines
12 KiB
TypeScript
418 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
acceptIssueThreadInteractionSchema,
|
|
askUserQuestionsResultSchema,
|
|
createIssueThreadInteractionSchema,
|
|
submitIssueThreadInteractionVerdictsSchema,
|
|
} from "./validators/issue.js";
|
|
|
|
describe("issue thread interaction schemas", () => {
|
|
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("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("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");
|
|
});
|
|
});
|