From a95739442027bdec8d291030a91e351dc434f635 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:15:11 -0500 Subject: [PATCH] [codex] Add structured issue-thread interactions (#4244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators supervise that work through issues, comments, approvals, and the board UI. > - Some agent proposals need structured board/user decisions, not hidden markdown conventions or heavyweight governed approvals. > - Issue-thread interactions already provide a natural thread-native surface for proposed tasks and questions. > - This pull request extends that surface with request confirmations, richer interaction cards, and agent/plugin/MCP helpers. > - The benefit is that plan approvals and yes/no decisions become explicit, auditable, and resumable without losing the single-issue workflow. ## What Changed - Added persisted issue-thread interactions for suggested tasks, structured questions, and request confirmations. - Added board UI cards for interaction review, selection, question answers, and accept/reject confirmation flows. - Added MCP and plugin SDK helpers for creating interaction cards from agents/plugins. - Updated agent wake instructions, onboarding assets, Paperclip skill docs, and public docs to prefer structured confirmations for issue-scoped decisions. - Rebased the branch onto `public-gh/master` and renumbered branch migrations to `0063` and `0064`; the idempotency migration uses `ADD COLUMN IF NOT EXISTS` for old branch users. ## Verification - `git diff --check public-gh/master..HEAD` - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts packages/mcp-server/src/tools.test.ts packages/shared/src/issue-thread-interactions.test.ts ui/src/lib/issue-thread-interactions.test.ts ui/src/lib/issue-chat-messages.test.ts ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/components/IssueChatThread.test.tsx server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/services/issue-thread-interactions.test.ts` -> 9 files / 79 tests passed - `pnpm -r typecheck` -> passed, including `packages/db` migration numbering check ## Risks - Medium: this adds a new issue-thread interaction model across db/shared/server/ui/plugin surfaces. - Migration risk is reduced by placing this branch after current master migrations (`0063`, `0064`) and making the idempotency column add idempotent for users who applied the old branch numbering. - UI interaction behavior is covered by component tests, but this PR does not include browser screenshots. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-class coding agent runtime. Exact model ID and context window are not exposed in this Paperclip run; tool use and local shell/code execution were enabled. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../company-import-export-e2e.test.ts | 2 +- cli/src/__tests__/worktree.test.ts | 6 +- docs/api/issues.md | 63 +- .../comments-and-communication.md | 12 + .../agent-developer/handling-approvals.md | 20 + .../agent-developer/heartbeat-protocol.md | 3 + docs/guides/agent-developer/task-workflow.md | 47 + .../adapter-utils/src/server-utils.test.ts | 5 + packages/adapter-utils/src/server-utils.ts | 3 + .../openclaw-gateway/src/server/execute.ts | 2 + .../0063_issue_thread_interactions.sql | 65 + ...4_issue_thread_interaction_idempotency.sql | 4 + packages/db/src/migrations/meta/_journal.json | 14 + packages/db/src/schema/index.ts | 1 + .../src/schema/issue_thread_interactions.ts | 54 + packages/mcp-server/README.md | 3 + packages/mcp-server/src/tools.test.ts | 84 ++ packages/mcp-server/src/tools.ts | 73 + .../plugins/sdk/src/host-client-factory.ts | 5 + packages/plugins/sdk/src/protocol.ts | 11 + packages/plugins/sdk/src/testing.ts | 47 + packages/plugins/sdk/src/types.ts | 35 + packages/plugins/sdk/src/worker-rpc-host.ts | 67 +- packages/shared/src/constants.ts | 26 + packages/shared/src/index.ts | 53 + .../src/issue-thread-interactions.test.ts | 123 ++ packages/shared/src/types/index.ts | 22 + packages/shared/src/types/issue.ts | 177 +++ packages/shared/src/validators/index.ts | 25 + packages/shared/src/validators/issue.ts | 258 +++- .../__tests__/adapter-routes-authz.test.ts | 48 +- server/src/__tests__/adapter-routes.test.ts | 60 +- .../__tests__/agent-live-run-routes.test.ts | 44 +- .../agent-permissions-routes.test.ts | 136 +- .../src/__tests__/agent-skills-routes.test.ts | 14 + .../approval-routes-idempotency.test.ts | 49 +- server/src/__tests__/assets.test.ts | 42 +- server/src/__tests__/cli-auth-routes.test.ts | 4 + .../company-portability-routes.test.ts | 48 +- .../__tests__/company-skills-routes.test.ts | 62 +- server/src/__tests__/costs-service.test.ts | 8 +- .../heartbeat-process-recovery.test.ts | 98 +- .../instance-settings-routes.test.ts | 6 +- .../src/__tests__/invite-create-route.test.ts | 57 +- .../__tests__/invite-summary-route.test.ts | 80 +- .../invite-test-resolution-route.test.ts | 83 +- .../issue-activity-events-routes.test.ts | 236 +-- ...ue-agent-mutation-ownership-routes.test.ts | 208 ++- .../__tests__/issue-attachment-routes.test.ts | 22 +- .../issue-closed-workspace-routes.test.ts | 32 + .../issue-comment-cancel-routes.test.ts | 158 +- .../issue-comment-reopen-routes.test.ts | 119 +- .../issue-document-restore-routes.test.ts | 187 ++- .../__tests__/issue-feedback-routes.test.ts | 5 + .../issue-thread-interaction-routes.test.ts | 584 ++++++++ .../issue-thread-interactions-service.test.ts | 881 ++++++++++++ ...issue-update-comment-wakeup-routes.test.ts | 6 + .../issue-workspace-command-authz.test.ts | 213 ++- .../issues-goal-context-routes.test.ts | 108 +- .../openclaw-invite-prompt-route.test.ts | 143 +- .../src/__tests__/plugin-routes-authz.test.ts | 71 +- .../plugin-scoped-api-routes.test.ts | 38 +- server/src/__tests__/routines-e2e.test.ts | 9 +- server/src/__tests__/routines-routes.test.ts | 17 + .../sidebar-preferences-routes.test.ts | 38 +- .../src/__tests__/user-profile-routes.test.ts | 19 +- .../workspace-runtime-routes-authz.test.ts | 10 +- server/src/onboarding-assets/ceo/AGENTS.md | 3 + server/src/onboarding-assets/ceo/HEARTBEAT.md | 3 + .../src/onboarding-assets/default/AGENTS.md | 3 + server/src/routes/access.ts | 29 + server/src/routes/issues.ts | 448 +++++- server/src/services/index.ts | 1 + .../issue-thread-interactions.test.ts | 215 +++ .../src/services/issue-thread-interactions.ts | 1152 +++++++++++++++ .../services/plugin-capability-validator.ts | 1 + server/src/services/plugin-host-services.ts | 26 +- skills/paperclip/SKILL.md | 3 + skills/paperclip/references/api-reference.md | 53 + ui/src/api/issues.ts | 20 + ui/src/components/IssueChatThread.test.tsx | 235 +++ ui/src/components/IssueChatThread.tsx | 85 +- .../IssueThreadInteractionCard.test.tsx | 258 ++++ .../components/IssueThreadInteractionCard.tsx | 1268 +++++++++++++++++ ui/src/context/LiveUpdatesProvider.tsx | 3 + .../issueThreadInteractionFixtures.ts | 537 +++++++ ui/src/lib/issue-chat-messages.test.ts | 91 ++ ui/src/lib/issue-chat-messages.ts | 31 + ui/src/lib/issue-thread-interactions.test.ts | 150 ++ ui/src/lib/issue-thread-interactions.ts | 140 ++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/IssueDetail.tsx | 151 ++ .../issue-thread-interactions.stories.tsx | 681 +++++++++ 93 files changed, 10089 insertions(+), 752 deletions(-) create mode 100644 packages/db/src/migrations/0063_issue_thread_interactions.sql create mode 100644 packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql create mode 100644 packages/db/src/schema/issue_thread_interactions.ts create mode 100644 packages/shared/src/issue-thread-interactions.test.ts create mode 100644 server/src/__tests__/issue-thread-interaction-routes.test.ts create mode 100644 server/src/__tests__/issue-thread-interactions-service.test.ts create mode 100644 server/src/services/issue-thread-interactions.test.ts create mode 100644 server/src/services/issue-thread-interactions.ts create mode 100644 ui/src/components/IssueThreadInteractionCard.test.tsx create mode 100644 ui/src/components/IssueThreadInteractionCard.tsx create mode 100644 ui/src/fixtures/issueThreadInteractionFixtures.ts create mode 100644 ui/src/lib/issue-thread-interactions.test.ts create mode 100644 ui/src/lib/issue-thread-interactions.ts create mode 100644 ui/storybook/stories/issue-thread-interactions.stories.tsx diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 6d7ac1d4..17adc571 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -503,5 +503,5 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { expect(importedFromZip.company.action).toBe("created"); expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true); - }, 60_000); + }, 90_000); }); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 98e2da77..28e10376 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -599,7 +599,7 @@ describe("worktree helpers", () => { fs.rmSync(tempRoot, { recursive: true, force: true }); } }, - 20000, + 30000, ); it("avoids ports already claimed by sibling worktree instance configs", async () => { @@ -881,7 +881,7 @@ describe("worktree helpers", () => { } fs.rmSync(tempRoot, { recursive: true, force: true }); } - }, 20_000); + }, 30_000); it("restores the current worktree config and instance data if reseed fails", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-")); @@ -1038,7 +1038,7 @@ describe("worktree helpers", () => { execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" }); fs.rmSync(tempRoot, { recursive: true, force: true }); } - }); + }, 15_000); it("creates and initializes a worktree from the top-level worktree:make command", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-")); diff --git a/docs/api/issues.md b/docs/api/issues.md index c95724c4..a0bff740 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -1,9 +1,9 @@ --- title: Issues -summary: Issue CRUD, checkout/release, comments, documents, and attachments +summary: Issue CRUD, checkout/release, comments, documents, interactions, and attachments --- -Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, keyed text documents, and file attachments. +Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, issue-thread interactions, keyed text documents, and file attachments. ## List Issues @@ -121,6 +121,65 @@ POST /api/issues/{issueId}/comments @-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent. +## Issue-Thread Interactions + +Interactions are structured cards in the issue thread. Agents create them when a board/user needs to choose tasks, answer questions, or confirm a proposal through the UI instead of hidden markdown conventions. + +### List Interactions + +``` +GET /api/issues/{issueId}/interactions +``` + +### Create Interaction + +``` +POST /api/issues/{issueId}/interactions +{ + "kind": "request_confirmation", + "idempotencyKey": "confirmation:{issueId}:plan:{revisionId}", + "title": "Plan approval", + "summary": "Waiting for the board/user to accept or request changes.", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Accept this plan?", + "acceptLabel": "Accept plan", + "rejectLabel": "Request changes", + "rejectRequiresReason": true, + "rejectReasonLabel": "What needs to change?", + "detailsMarkdown": "Review the latest plan document before accepting.", + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "documentId": "{documentId}", + "key": "plan", + "revisionId": "{latestRevisionId}", + "revisionNumber": 3 + } + } +} +``` + +Supported `kind` values: + +- `suggest_tasks`: propose child issues for the board/user to accept or reject +- `ask_user_questions`: ask structured questions and store selected answers +- `request_confirmation`: ask the board/user to accept or reject a proposal + +For `request_confirmation`, `continuationPolicy: "wake_assignee"` wakes the assignee only after acceptance. Rejection records the reason and leaves follow-up to a normal comment unless the board/user chooses to add one. + +### Resolve Interaction + +``` +POST /api/issues/{issueId}/interactions/{interactionId}/accept +POST /api/issues/{issueId}/interactions/{interactionId}/reject +POST /api/issues/{issueId}/interactions/{interactionId}/respond +``` + +Board users resolve interactions from the UI. Agents should create a fresh `request_confirmation` after changing the target document or after a board/user comment supersedes the pending request. + ## Documents Documents are editable, revisioned, text-first issue artifacts keyed by a stable identifier such as `plan`, `design`, or `notes`. diff --git a/docs/guides/agent-developer/comments-and-communication.md b/docs/guides/agent-developer/comments-and-communication.md index 410ef218..23a77d45 100644 --- a/docs/guides/agent-developer/comments-and-communication.md +++ b/docs/guides/agent-developer/comments-and-communication.md @@ -55,3 +55,15 @@ The name must match the agent's `name` field exactly (case-insensitive). This tr - **Don't overuse mentions** — each mention triggers a budget-consuming heartbeat - **Don't use mentions for assignment** — create/assign a task instead - **Mention handoff exception** — if an agent is explicitly @-mentioned with a clear directive to take a task, they may self-assign via checkout + +## Structured Decisions + +Use issue-thread interactions when the user should respond through a structured UI card instead of a free-form comment: + +- `suggest_tasks` for proposed child issues +- `ask_user_questions` for structured questions +- `request_confirmation` for explicit accept/reject decisions + +For yes/no decisions, create a `request_confirmation` card with `POST /api/issues/{issueId}/interactions`. Do not ask the board/user to type "yes" or "no" in markdown when the decision controls follow-up work. + +Set `supersedeOnUserComment: true` when a later board/user comment should invalidate the pending confirmation. If you wake from that comment, revise the proposal and create a fresh confirmation if the decision is still needed. diff --git a/docs/guides/agent-developer/handling-approvals.md b/docs/guides/agent-developer/handling-approvals.md index a984f685..72df7c66 100644 --- a/docs/guides/agent-developer/handling-approvals.md +++ b/docs/guides/agent-developer/handling-approvals.md @@ -5,6 +5,16 @@ summary: Agent-side approval request and response Agents interact with the approval system in two ways: requesting approvals and responding to approval resolutions. +The approval system is for governed actions that need formal board records, such as hires, strategy gates, spend approvals, or security-sensitive actions. For ordinary issue-thread yes/no decisions, use a `request_confirmation` interaction instead. + +Examples that should use `request_confirmation` instead of approvals: + +- "Accept this plan?" +- "Proceed with this issue breakdown?" +- "Use option A or reject and request changes?" + +Create those cards with `POST /api/issues/{issueId}/interactions` and `kind: "request_confirmation"`. + ## Requesting a Hire Managers and CEOs can request to hire new agents: @@ -37,6 +47,16 @@ POST /api/companies/{companyId}/approvals } ``` +## Plan Approval Cards + +For normal issue implementation plans, use the issue-thread confirmation surface: + +1. Update the `plan` issue document. +2. Create `request_confirmation` bound to the latest `plan` revision. +3. Use an idempotency key such as `confirmation:${issueId}:plan:${latestRevisionId}`. +4. Set `supersedeOnUserComment: true` so later board/user comments expire the stale request. +5. Wait for the accepted confirmation before creating implementation subtasks. + ## Responding to Approval Resolutions When an approval you requested is resolved, you may be woken with: diff --git a/docs/guides/agent-developer/heartbeat-protocol.md b/docs/guides/agent-developer/heartbeat-protocol.md index b846d7ab..e1273a21 100644 --- a/docs/guides/agent-developer/heartbeat-protocol.md +++ b/docs/guides/agent-developer/heartbeat-protocol.md @@ -70,6 +70,8 @@ Use your tools and capabilities to complete the task. If the issue is actionable Leave durable progress in comments, documents, or work products, and include the next action before exiting. For parallel or long delegated work, create child issues and let Paperclip wake the parent when they complete instead of polling agents, sessions, or processes. +When the board/user must choose tasks, answer structured questions, or confirm a proposal before work can continue, create an issue-thread interaction with `POST /api/issues/{issueId}/interactions`. Use `request_confirmation` for explicit yes/no decisions instead of asking for them in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest revision, and wait for acceptance before creating implementation subtasks. + ### Step 8: Update Status Always include the run ID header on state changes: @@ -107,6 +109,7 @@ Always set `parentId` and `goalId` on subtasks. - **Start actionable work** in the same heartbeat; planning-only exits are for planning tasks - **Leave a clear next action** in durable issue context - **Use child issues instead of polling** for long or parallel delegated work +- **Use `request_confirmation`** for issue-scoped yes/no decisions and plan approval cards - **Always set parentId** on subtasks - **Never cancel cross-team tasks** — reassign to your manager - **Escalate when stuck** — use your chain of command diff --git a/docs/guides/agent-developer/task-workflow.md b/docs/guides/agent-developer/task-workflow.md index 3b7e1403..fa07c5d5 100644 --- a/docs/guides/agent-developer/task-workflow.md +++ b/docs/guides/agent-developer/task-workflow.md @@ -68,6 +68,53 @@ POST /api/companies/{companyId}/issues Always set `parentId` to maintain the task hierarchy. Set `goalId` when applicable. +## Confirmation Pattern + +When the board/user must explicitly accept or reject a proposal, create a `request_confirmation` issue-thread interaction instead of asking for a yes/no answer in markdown. + +``` +POST /api/issues/{issueId}/interactions +{ + "kind": "request_confirmation", + "idempotencyKey": "confirmation:{issueId}:{targetKey}:{targetVersion}", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Accept this proposal?", + "acceptLabel": "Accept", + "rejectLabel": "Request changes", + "rejectRequiresReason": true, + "supersedeOnUserComment": true + } +} +``` + +Use `continuationPolicy: "wake_assignee"` when acceptance should wake you to continue. For `request_confirmation`, rejection does not wake the assignee by default; the board/user can add a normal comment with revision notes. + +## Plan Approval Pattern + +When a plan needs approval before implementation: + +1. Create or update the issue document with key `plan`. +2. Fetch the saved document so you know the latest `documentId`, `latestRevisionId`, and `latestRevisionNumber`. +3. Create a `request_confirmation` targeting that exact `plan` revision. +4. Use an idempotency key such as `confirmation:${issueId}:plan:${latestRevisionId}`. +5. Wait for acceptance before creating implementation subtasks. +6. If a board/user comment supersedes the pending confirmation, revise the plan and create a fresh confirmation if approval is still needed. + +Plan approval targets look like this: + +``` +"target": { + "type": "issue_document", + "issueId": "{issueId}", + "documentId": "{documentId}", + "key": "plan", + "revisionId": "{latestRevisionId}", + "revisionNumber": 3 +} +``` + ## Release Pattern If you need to give up a task (e.g. you realize it should go to someone else): diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 4579e167..4faceaae 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -256,6 +256,11 @@ describe("renderPaperclipWakePrompt", () => { expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Create child issues directly when you know what needs to be done"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("POST /api/issues/{issueId}/interactions"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("kind suggest_tasks, ask_user_questions, or request_confirmation"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("confirmation:{issueId}:plan:{revisionId}"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Wait for acceptance before creating implementation subtasks"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain( "Respect budget, pause/cancel, approval gates, and company boundaries", ); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 88acc936..728fe01f 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -84,6 +84,9 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ "- Leave durable progress in comments, documents, or work products with a clear next action.", "- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.", "- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.", + "- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.", + "- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response; for request_confirmation this resumes only after acceptance.", + "- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.", "- If blocked, mark the issue blocked and name the unblock owner and action.", "- Respect budget, pause/cancel, approval gates, and company boundaries.", ].join("\n"); diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 5171ba2f..23ceaeb2 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -422,6 +422,8 @@ function buildWakeText( " - GET /api/issues/{issueId}/comments", " - Execute the issue instructions exactly. If the issue is actionable, take concrete action in this run; do not stop at a plan unless planning was requested.", " - Leave durable progress with a clear next action. Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.", + " - Create child issues directly when you know what needs to be done; use POST /api/issues/{issueId}/interactions with kind suggest_tasks, ask_user_questions, or request_confirmation when the board/user must choose, answer, or confirm before you can continue.", + " - For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}; wait for acceptance before creating implementation subtasks.", " - If blocked, PATCH /api/issues/{issueId} with {\"status\":\"blocked\",\"comment\":\"what is blocked, who owns the unblock, and the next action\"}.", " - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.", " - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.", diff --git a/packages/db/src/migrations/0063_issue_thread_interactions.sql b/packages/db/src/migrations/0063_issue_thread_interactions.sql new file mode 100644 index 00000000..121b0975 --- /dev/null +++ b/packages/db/src/migrations/0063_issue_thread_interactions.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS "issue_thread_interactions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "kind" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "continuation_policy" text DEFAULT 'wake_assignee' NOT NULL, + "source_comment_id" uuid, + "source_run_id" uuid, + "title" text, + "summary" text, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "resolved_by_agent_id" uuid, + "resolved_by_user_id" text, + "payload" jsonb NOT NULL, + "result" jsonb, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_company_id_companies_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_comment_id_issue_comments_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_comment_id_issue_comments_id_fk" FOREIGN KEY ("source_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_resolved_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_issue_idx" ON "issue_thread_interactions" USING btree ("issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_created_at_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_status_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_source_comment_idx" ON "issue_thread_interactions" USING btree ("source_comment_id"); diff --git a/packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql b/packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql new file mode 100644 index 00000000..27461e6f --- /dev/null +++ b/packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql @@ -0,0 +1,4 @@ +ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "idempotency_key" text;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_idempotency_uq" + ON "issue_thread_interactions" USING btree ("company_id","issue_id","idempotency_key") + WHERE "issue_thread_interactions"."idempotency_key" IS NOT NULL; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 9a1a4700..82cdd65d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -442,6 +442,20 @@ "when": 1776780000000, "tag": "0062_routine_run_dispatch_fingerprint", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1776780001000, + "tag": "0063_issue_thread_interactions", + "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1776780002000, + "tag": "0064_issue_thread_interaction_idempotency", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 75dccf1b..d1914344 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -35,6 +35,7 @@ export { labels } from "./labels.js"; export { issueLabels } from "./issue_labels.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; +export { issueThreadInteractions } from "./issue_thread_interactions.js"; export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueInboxArchives } from "./issue_inbox_archives.js"; export { inboxDismissals } from "./inbox_dismissals.js"; diff --git a/packages/db/src/schema/issue_thread_interactions.ts b/packages/db/src/schema/issue_thread_interactions.ts new file mode 100644 index 00000000..75895953 --- /dev/null +++ b/packages/db/src/schema/issue_thread_interactions.ts @@ -0,0 +1,54 @@ +import type { + IssueThreadInteractionPayload, + IssueThreadInteractionResult, +} from "@paperclipai/shared"; +import { sql } from "drizzle-orm"; +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issueComments } from "./issue_comments.js"; +import { issues } from "./issues.js"; + +export const issueThreadInteractions = pgTable( + "issue_thread_interactions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull().references(() => issues.id), + kind: text("kind").notNull(), + status: text("status").notNull().default("pending"), + continuationPolicy: text("continuation_policy").notNull().default("wake_assignee"), + idempotencyKey: text("idempotency_key"), + sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }), + sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + title: text("title"), + summary: text("summary"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id), + createdByUserId: text("created_by_user_id"), + resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id), + resolvedByUserId: text("resolved_by_user_id"), + payload: jsonb("payload").$type().notNull(), + result: jsonb("result").$type(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + issueIdx: index("issue_thread_interactions_issue_idx").on(table.issueId), + companyIssueCreatedAtIdx: index("issue_thread_interactions_company_issue_created_at_idx").on( + table.companyId, + table.issueId, + table.createdAt, + ), + companyIssueStatusIdx: index("issue_thread_interactions_company_issue_status_idx").on( + table.companyId, + table.issueId, + table.status, + ), + companyIssueIdempotencyUq: uniqueIndex("issue_thread_interactions_company_issue_idempotency_uq") + .on(table.companyId, table.issueId, table.idempotencyKey) + .where(sql`${table.idempotencyKey} IS NOT NULL`), + sourceCommentIdx: index("issue_thread_interactions_source_comment_idx").on(table.sourceCommentId), + }), +); diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 4923e8f1..1d5881fd 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -63,6 +63,9 @@ Write tools: - `paperclipCheckoutIssue` - `paperclipReleaseIssue` - `paperclipAddComment` +- `paperclipSuggestTasks` +- `paperclipAskUserQuestions` +- `paperclipRequestConfirmation` - `paperclipUpsertIssueDocument` - `paperclipRestoreIssueDocumentRevision` - `paperclipControlIssueWorkspaceServices` diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index d9844631..4452a153 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -182,6 +182,90 @@ describe("paperclip MCP tools", () => { expect(response.content[0]?.text).toContain("http://127.0.0.1:5173"); }); + it("creates suggest_tasks interactions with the expected issue-scoped payload", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse({ id: "interaction-1", kind: "suggest_tasks" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("paperclipSuggestTasks"); + await tool.execute({ + issueId: "PAP-1135", + idempotencyKey: "run-1:suggest", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + idempotencyKey: "run-1:suggest", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + }); + + it("creates request_confirmation interactions with plan target payloads", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse({ id: "interaction-1", kind: "request_confirmation" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("paperclipRequestConfirmation"); + await tool.execute({ + issueId: "PAP-1135", + idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333", + title: "Plan approval", + payload: { + version: 1, + prompt: "Accept this plan?", + acceptLabel: "Accept plan", + allowDeclineReason: true, + rejectLabel: "Request changes", + rejectRequiresReason: true, + supersedeOnUserComment: true, + target: { + type: "issue_document", + key: "plan", + revisionId: "33333333-3333-4333-8333-333333333333", + revisionNumber: 3, + }, + }, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + kind: "request_confirmation", + continuationPolicy: "none", + idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333", + title: "Plan approval", + payload: { + version: 1, + prompt: "Accept this plan?", + acceptLabel: "Accept plan", + allowDeclineReason: true, + rejectLabel: "Request changes", + rejectRequiresReason: true, + supersedeOnUserComment: true, + target: { + type: "issue_document", + key: "plan", + revisionId: "33333333-3333-4333-8333-333333333333", + revisionNumber: 3, + }, + }, + }); + }); + it("creates approvals with the expected company-scoped payload", async () => { const fetchMock = vi.fn().mockResolvedValue( mockJsonResponse({ id: "approval-1" }), diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 77b03ff7..f101f3fe 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -1,9 +1,13 @@ import { z } from "zod"; import { addIssueCommentSchema, + askUserQuestionsPayloadSchema, checkoutIssueSchema, createApprovalSchema, createIssueSchema, + issueThreadInteractionContinuationPolicySchema, + requestConfirmationPayloadSchema, + suggestTasksPayloadSchema, updateIssueSchema, upsertIssueDocumentSchema, linkIssueApprovalSchema, @@ -107,6 +111,39 @@ const addCommentToolSchema = z.object({ issueId: issueIdSchema, }).merge(addIssueCommentSchema); +const createSuggestTasksToolSchema = z.object({ + issueId: issueIdSchema, + 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: suggestTasksPayloadSchema, +}); + +const createAskUserQuestionsToolSchema = z.object({ + issueId: issueIdSchema, + 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: askUserQuestionsPayloadSchema, +}); + +const createRequestConfirmationToolSchema = z.object({ + issueId: issueIdSchema, + 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("none"), + payload: requestConfirmationPayloadSchema, +}); + const approvalDecisionSchema = z.object({ approvalId: approvalIdSchema, action: z.enum(["approve", "reject", "requestRevision", "resubmit"]), @@ -443,6 +480,42 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio async ({ issueId, ...body }) => client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }), ), + makeTool( + "paperclipSuggestTasks", + "Create a suggest_tasks interaction on an issue", + createSuggestTasksToolSchema, + async ({ issueId, ...body }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, { + body: { + kind: "suggest_tasks", + ...body, + }, + }), + ), + makeTool( + "paperclipAskUserQuestions", + "Create an ask_user_questions interaction on an issue", + createAskUserQuestionsToolSchema, + async ({ issueId, ...body }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, { + body: { + kind: "ask_user_questions", + ...body, + }, + }), + ), + makeTool( + "paperclipRequestConfirmation", + "Create a request_confirmation interaction on an issue", + createRequestConfirmationToolSchema, + async ({ issueId, ...body }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, { + body: { + kind: "request_confirmation", + ...body, + }, + }), + ), makeTool( "paperclipUpsertIssueDocument", "Create or update an issue document", diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 40417459..8e1af813 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -184,6 +184,7 @@ export interface HostServices { getOrchestrationSummary(params: WorkerToHostMethods["issues.summaries.getOrchestration"][0]): Promise; listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise; createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise; + createInteraction(params: WorkerToHostMethods["issues.createInteraction"][0]): Promise; }; /** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */ @@ -342,6 +343,7 @@ const METHOD_CAPABILITY_MAP: Record { return services.issues.createComment(params); }), + "issues.createInteraction": gated("issues.createInteraction", async (params) => { + return services.issues.createInteraction(params); + }), // Issue Documents "issues.documents.list": gated("issues.documents.list", async (params) => { diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index f96db65b..dc325452 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -27,6 +27,8 @@ import type { IssueComment, IssueDocument, IssueDocumentSummary, + IssueThreadInteraction, + CreateIssueThreadInteraction, Agent, Goal, } from "@paperclipai/shared"; @@ -746,6 +748,15 @@ export interface WorkerToHostMethods { params: { issueId: string; body: string; companyId: string; authorAgentId?: string }, result: IssueComment, ]; + "issues.createInteraction": [ + params: { + issueId: string; + companyId: string; + interaction: CreateIssueThreadInteraction; + authorAgentId?: string | null; + }, + result: IssueThreadInteraction, + ]; // Issue Documents "issues.documents.list": [ diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 0a041643..5f3ea1f9 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -8,6 +8,8 @@ import type { Project, Issue, IssueComment, + IssueThreadInteraction, + CreateIssueThreadInteraction, IssueDocument, Agent, Goal, @@ -149,6 +151,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const issues = new Map(); const blockedByIssueIds = new Map(); const issueComments = new Map(); + const issueInteractions = new Map(); const issueDocuments = new Map(); const agents = new Map(); const goals = new Map(); @@ -547,6 +550,50 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { issueComments.set(issueId, current); return comment; }, + async createInteraction(issueId, interaction, companyId, options) { + requireCapability(manifest, capabilitySet, "issue.interactions.create"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } + const now = new Date(); + const current = issueInteractions.get(issueId) ?? []; + if (interaction.idempotencyKey) { + const existing = current.find((entry) => entry.idempotencyKey === interaction.idempotencyKey); + if (existing) return existing; + } + const created: IssueThreadInteraction = { + id: randomUUID(), + companyId: parentIssue.companyId, + issueId, + kind: interaction.kind, + status: "pending", + continuationPolicy: interaction.continuationPolicy ?? "wake_assignee", + idempotencyKey: interaction.idempotencyKey ?? null, + sourceCommentId: interaction.sourceCommentId ?? null, + sourceRunId: interaction.sourceRunId ?? null, + title: interaction.title ?? null, + summary: interaction.summary ?? null, + createdByAgentId: options?.authorAgentId ?? null, + createdByUserId: null, + payload: interaction.payload, + result: null, + createdAt: now, + updatedAt: now, + } as IssueThreadInteraction; + current.push(created); + issueInteractions.set(issueId, current); + return created; + }, + async suggestTasks(issueId, interaction, companyId, options) { + return this.createInteraction(issueId, { ...interaction, kind: "suggest_tasks" }, companyId, options) as Promise; + }, + async askUserQuestions(issueId, interaction, companyId, options) { + return this.createInteraction(issueId, { ...interaction, kind: "ask_user_questions" }, companyId, options) as Promise; + }, + async requestConfirmation(issueId, interaction, companyId, options) { + return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise; + }, documents: { async list(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 4639b73f..367905a1 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -22,6 +22,11 @@ import type { IssueDocument, IssueDocumentSummary, IssueRelationIssueSummary, + IssueThreadInteraction, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + CreateIssueThreadInteraction, PluginIssueOriginKind, Agent, Goal, @@ -80,6 +85,11 @@ export type { IssueDocument, IssueDocumentSummary, IssueRelationIssueSummary, + IssueThreadInteraction, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + CreateIssueThreadInteraction, PluginIssueOriginKind, Agent, Goal, @@ -1078,6 +1088,7 @@ export interface PluginIssueSummariesClient { * - `issues.orchestration.read` for orchestration summaries * - `issue.comments.read` for `listComments` * - `issue.comments.create` for `createComment` + * - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, and `requestConfirmation` * - `issue.documents.read` for `documents.list` and `documents.get` * - `issue.documents.write` for `documents.upsert` and `documents.delete` */ @@ -1182,6 +1193,30 @@ export interface PluginIssuesClient { companyId: string, options?: { authorAgentId?: string }, ): Promise; + createInteraction( + issueId: string, + interaction: CreateIssueThreadInteraction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; + suggestTasks( + issueId: string, + interaction: Omit, "kind">, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; + askUserQuestions( + issueId: string, + interaction: Omit, "kind">, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; + requestConfirmation( + issueId: string, + interaction: Omit, "kind">, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ documents: PluginIssueDocumentsClient; /** Read and write blocker relationships. */ diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index db3fadb2..3f1f5a9d 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -38,7 +38,12 @@ import path from "node:path"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { fileURLToPath } from "node:url"; -import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import type { + AskUserQuestionsInteraction, + PaperclipPluginManifestV1, + RequestConfirmationInteraction, + SuggestTasksInteraction, +} from "@paperclipai/shared"; import type { PaperclipPlugin } from "./define-plugin.js"; import type { @@ -692,6 +697,66 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId }); }, + async createInteraction(issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }) { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction, + authorAgentId: options?.authorAgentId, + }); + }, + + async suggestTasks( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction: { + ...interaction, + kind: "suggest_tasks", + }, + authorAgentId: options?.authorAgentId, + }) as Promise; + }, + + async askUserQuestions( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction: { + ...interaction, + kind: "ask_user_questions", + }, + authorAgentId: options?.authorAgentId, + }) as Promise; + }, + + async requestConfirmation( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction: { + ...interaction, + kind: "request_confirmation", + }, + authorAgentId: options?.authorAgentId, + }) as Promise; + }, + documents: { async list(issueId: string, companyId: string) { return callHost("issues.documents.list", { issueId, companyId }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0a5ee82e..90e97db1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -137,6 +137,31 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join("," export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; +export const ISSUE_THREAD_INTERACTION_KINDS = [ + "suggest_tasks", + "ask_user_questions", + "request_confirmation", +] as const; +export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; + +export const ISSUE_THREAD_INTERACTION_STATUSES = [ + "pending", + "accepted", + "rejected", + "answered", + "expired", + "failed", +] as const; +export type IssueThreadInteractionStatus = (typeof ISSUE_THREAD_INTERACTION_STATUSES)[number]; + +export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [ + "none", + "wake_assignee", + "wake_assignee_on_accept", +] as const; +export type IssueThreadInteractionContinuationPolicy = + (typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number]; + export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const; export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; export type PluginIssueOriginKind = `plugin:${string}`; @@ -523,6 +548,7 @@ export const PLUGIN_CAPABILITIES = [ "issues.checkout", "issues.wakeup", "issue.comments.create", + "issue.interactions.create", "issue.documents.write", "agents.pause", "agents.resume", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5b1a291f..3fcf5e4a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,9 @@ export { INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, + ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_STATUSES, + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_ORIGIN_KINDS, ISSUE_RELATION_TYPES, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, @@ -105,6 +108,9 @@ export { type AgentIconName, type IssueStatus, type IssuePriority, + type IssueThreadInteractionKind, + type IssueThreadInteractionStatus, + type IssueThreadInteractionContinuationPolicy, type BuiltInIssueOriginKind, type PluginIssueOriginKind, type IssueOriginKind, @@ -300,6 +306,28 @@ export type { IssueExecutionStagePrincipal, IssueExecutionDecision, IssueComment, + IssueThreadInteractionActorFields, + SuggestedTaskDraft, + SuggestTasksPayload, + SuggestTasksResultCreatedTask, + SuggestTasksResult, + AskUserQuestionsQuestionOption, + AskUserQuestionsQuestion, + AskUserQuestionsPayload, + AskUserQuestionsAnswer, + AskUserQuestionsResult, + RequestConfirmationIssueDocumentTarget, + RequestConfirmationCustomTarget, + RequestConfirmationTarget, + RequestConfirmationPayload, + RequestConfirmationResult, + IssueThreadInteractionBase, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + IssueThreadInteraction, + IssueThreadInteractionPayload, + IssueThreadInteractionResult, IssueDocument, IssueDocumentSummary, DocumentRevision, @@ -555,6 +583,27 @@ export { issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, + issueThreadInteractionStatusSchema, + issueThreadInteractionKindSchema, + issueThreadInteractionContinuationPolicySchema, + suggestedTaskDraftSchema, + suggestTasksPayloadSchema, + suggestTasksResultCreatedTaskSchema, + suggestTasksResultSchema, + askUserQuestionsQuestionOptionSchema, + askUserQuestionsQuestionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsAnswerSchema, + askUserQuestionsResultSchema, + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, + requestConfirmationTargetSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, + createIssueThreadInteractionSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, @@ -580,6 +629,10 @@ export { type UpdateIssue, type CheckoutIssue, type AddIssueComment, + type CreateIssueThreadInteraction, + type AcceptIssueThreadInteraction, + type RejectIssueThreadInteraction, + type RespondIssueThreadInteraction, 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 new file mode 100644 index 00000000..f17ee0ec --- /dev/null +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { createIssueThreadInteractionSchema } 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", () => { + 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: "https://example.com/checklist", + }, + }, + }); + + 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", + }); + }); + + 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"]) { + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + target: { + ...base.payload.target, + href, + }, + }, + })).toThrow("href must not use javascript:, data:, or protocol-relative URLs"); + } + }); +}); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 00c9d85d..7a643375 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -114,6 +114,28 @@ export type { IssueExecutionStagePrincipal, IssueExecutionDecision, IssueComment, + IssueThreadInteractionActorFields, + SuggestedTaskDraft, + SuggestTasksPayload, + SuggestTasksResultCreatedTask, + SuggestTasksResult, + AskUserQuestionsQuestionOption, + AskUserQuestionsQuestion, + AskUserQuestionsPayload, + AskUserQuestionsAnswer, + AskUserQuestionsResult, + RequestConfirmationIssueDocumentTarget, + RequestConfirmationCustomTarget, + RequestConfirmationTarget, + RequestConfirmationPayload, + RequestConfirmationResult, + IssueThreadInteractionBase, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + IssueThreadInteraction, + IssueThreadInteractionPayload, + IssueThreadInteractionResult, IssueDocument, IssueDocumentSummary, DocumentRevision, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 3307b534..f105ab5c 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -6,6 +6,9 @@ import type { IssueExecutionStateStatus, IssueOriginKind, IssuePriority, + IssueThreadInteractionContinuationPolicy, + IssueThreadInteractionKind, + IssueThreadInteractionStatus, IssueStatus, } from "../constants.js"; import type { Goal } from "./goal.js"; @@ -263,6 +266,180 @@ export interface IssueComment { updatedAt: Date; } +export interface IssueThreadInteractionActorFields { + createdByAgentId?: string | null; + createdByUserId?: string | null; + resolvedByAgentId?: string | null; + resolvedByUserId?: string | null; +} + +export interface SuggestedTaskDraft { + clientKey: string; + parentClientKey?: string | null; + parentId?: string | null; + title: string; + description?: string | null; + priority?: IssuePriority | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + projectId?: string | null; + goalId?: string | null; + billingCode?: string | null; + labels?: string[]; + hiddenInPreview?: boolean; +} + +export interface SuggestTasksPayload { + version: 1; + defaultParentId?: string | null; + tasks: SuggestedTaskDraft[]; +} + +export interface SuggestTasksResultCreatedTask { + clientKey: string; + issueId: string; + identifier?: string | null; + title?: string | null; + parentIssueId?: string | null; + parentIdentifier?: string | null; +} + +export interface SuggestTasksResult { + version: 1; + createdTasks?: SuggestTasksResultCreatedTask[]; + skippedClientKeys?: string[]; + rejectionReason?: string | null; +} + +export interface AskUserQuestionsQuestionOption { + id: string; + label: string; + description?: string | null; +} + +export interface AskUserQuestionsQuestion { + id: string; + prompt: string; + helpText?: string | null; + selectionMode: "single" | "multi"; + required?: boolean; + options: AskUserQuestionsQuestionOption[]; +} + +export interface AskUserQuestionsPayload { + version: 1; + title?: string | null; + submitLabel?: string | null; + questions: AskUserQuestionsQuestion[]; +} + +export interface AskUserQuestionsAnswer { + questionId: string; + optionIds: string[]; +} + +export interface AskUserQuestionsResult { + version: 1; + answers: AskUserQuestionsAnswer[]; + summaryMarkdown?: string | null; +} + +export interface RequestConfirmationIssueDocumentTarget { + type: "issue_document"; + issueId?: string | null; + documentId?: string | null; + key: string; + revisionId: string; + revisionNumber?: number | null; + label?: string | null; + href?: string | null; +} + +export interface RequestConfirmationCustomTarget { + type: "custom"; + key: string; + revisionId?: string | null; + revisionNumber?: number | null; + label?: string | null; + href?: string | null; +} + +export type RequestConfirmationTarget = + | RequestConfirmationIssueDocumentTarget + | RequestConfirmationCustomTarget; + +export interface RequestConfirmationPayload { + version: 1; + prompt: string; + acceptLabel?: string | null; + rejectLabel?: string | null; + rejectRequiresReason?: boolean; + rejectReasonLabel?: string | null; + allowDeclineReason?: boolean; + declineReasonPlaceholder?: string | null; + detailsMarkdown?: string | null; + supersedeOnUserComment?: boolean; + target?: RequestConfirmationTarget | null; +} + +export interface RequestConfirmationResult { + version: 1; + outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target"; + reason?: string | null; + commentId?: string | null; + staleTarget?: RequestConfirmationTarget | null; +} + +export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { + id: string; + companyId: string; + issueId: string; + kind: IssueThreadInteractionKind; + idempotencyKey?: string | null; + sourceCommentId?: string | null; + sourceRunId?: string | null; + title?: string | null; + summary?: string | null; + status: IssueThreadInteractionStatus; + continuationPolicy: IssueThreadInteractionContinuationPolicy; + createdAt: Date | string; + updatedAt: Date | string; + resolvedAt?: Date | string | null; +} + +export interface SuggestTasksInteraction extends IssueThreadInteractionBase { + kind: "suggest_tasks"; + payload: SuggestTasksPayload; + result?: SuggestTasksResult | null; +} + +export interface AskUserQuestionsInteraction extends IssueThreadInteractionBase { + kind: "ask_user_questions"; + payload: AskUserQuestionsPayload; + result?: AskUserQuestionsResult | null; +} + +export interface RequestConfirmationInteraction extends IssueThreadInteractionBase { + kind: "request_confirmation"; + payload: RequestConfirmationPayload; + result?: RequestConfirmationResult | null; +} + +export type IssueThreadInteraction = + | SuggestTasksInteraction + | AskUserQuestionsInteraction + | RequestConfirmationInteraction; + +export type IssueThreadInteractionPayload = + | SuggestTasksPayload + | AskUserQuestionsPayload + | RequestConfirmationPayload; + +export type IssueThreadInteractionResult = + | SuggestTasksResult + | AskUserQuestionsResult + | RequestConfirmationResult; + export interface IssueAttachment { id: string; companyId: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 99720389..b7ae92fd 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -142,6 +142,27 @@ export { issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, + issueThreadInteractionStatusSchema, + issueThreadInteractionKindSchema, + issueThreadInteractionContinuationPolicySchema, + suggestedTaskDraftSchema, + suggestTasksPayloadSchema, + suggestTasksResultCreatedTaskSchema, + suggestTasksResultSchema, + askUserQuestionsQuestionOptionSchema, + askUserQuestionsQuestionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsAnswerSchema, + askUserQuestionsResultSchema, + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, + requestConfirmationTargetSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, + createIssueThreadInteractionSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, issueDocumentFormatSchema, @@ -155,6 +176,10 @@ export { type IssueExecutionWorkspaceSettings, type CheckoutIssue, type AddIssueComment, + type CreateIssueThreadInteraction, + type AcceptIssueThreadInteraction, + type RejectIssueThreadInteraction, + type RespondIssueThreadInteraction, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type IssueDocumentFormat, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 6b11570d..6fed23d5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -6,6 +6,9 @@ import { ISSUE_EXECUTION_STATE_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES, + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, + ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_STATUSES, } from "../constants.js"; export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [ @@ -183,6 +186,254 @@ export const addIssueCommentSchema = z.object({ export type AddIssueComment = z.infer; +export const issueThreadInteractionStatusSchema = z.enum(ISSUE_THREAD_INTERACTION_STATUSES); +export const issueThreadInteractionKindSchema = z.enum(ISSUE_THREAD_INTERACTION_KINDS); +export const issueThreadInteractionContinuationPolicySchema = z.enum( + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, +); + +export const issueDocumentKeySchema = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -"); + +export const suggestedTaskDraftSchema = z.object({ + clientKey: z.string().trim().min(1).max(120), + parentClientKey: z.string().trim().min(1).max(120).nullable().optional(), + parentId: z.string().uuid().nullable().optional(), + title: z.string().trim().min(1).max(240), + description: z.string().trim().max(20000).nullable().optional(), + priority: z.enum(ISSUE_PRIORITIES).nullable().optional(), + assigneeAgentId: z.string().uuid().nullable().optional(), + assigneeUserId: z.string().trim().min(1).nullable().optional(), + projectId: z.string().uuid().nullable().optional(), + goalId: z.string().uuid().nullable().optional(), + billingCode: z.string().trim().max(120).nullable().optional(), + labels: z.array(z.string().trim().min(1).max(48)).max(20).optional(), + hiddenInPreview: z.boolean().optional(), +}).superRefine((value, ctx) => { + if (value.assigneeAgentId && value.assigneeUserId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Suggested tasks can only target one assignee", + path: ["assigneeAgentId"], + }); + } +}); + +export const suggestTasksPayloadSchema = z.object({ + version: z.literal(1), + defaultParentId: z.string().uuid().nullable().optional(), + tasks: z.array(suggestedTaskDraftSchema).min(1).max(50), +}).superRefine((value, ctx) => { + const seenClientKeys = new Set(); + for (const [index, task] of value.tasks.entries()) { + if (seenClientKeys.has(task.clientKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "clientKey must be unique within one interaction", + path: ["tasks", index, "clientKey"], + }); + continue; + } + seenClientKeys.add(task.clientKey); + } +}); + +export const suggestTasksResultCreatedTaskSchema = z.object({ + clientKey: z.string().trim().min(1).max(120), + issueId: z.string().uuid(), + identifier: z.string().trim().min(1).nullable().optional(), + title: z.string().trim().min(1).nullable().optional(), + parentIssueId: z.string().uuid().nullable().optional(), + parentIdentifier: z.string().trim().min(1).nullable().optional(), +}); + +export const suggestTasksResultSchema = z.object({ + version: z.literal(1), + createdTasks: z.array(suggestTasksResultCreatedTaskSchema).max(50).optional(), + skippedClientKeys: z.array(z.string().trim().min(1).max(120)).max(50).optional(), + rejectionReason: z.string().trim().max(4000).nullable().optional(), +}); + +export const askUserQuestionsQuestionOptionSchema = 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(), +}); + +export const askUserQuestionsQuestionSchema = z.object({ + id: z.string().trim().min(1).max(120), + prompt: z.string().trim().min(1).max(500), + helpText: z.string().trim().max(1000).nullable().optional(), + selectionMode: z.enum(["single", "multi"]), + required: z.boolean().optional(), + options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(10), +}); + +export const askUserQuestionsPayloadSchema = z.object({ + version: z.literal(1), + title: z.string().trim().max(240).nullable().optional(), + submitLabel: z.string().trim().max(120).nullable().optional(), + questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10), +}).superRefine((value, ctx) => { + const seenQuestionIds = new Set(); + for (const [questionIndex, question] of value.questions.entries()) { + if (seenQuestionIds.has(question.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Question ids must be unique within one interaction", + path: ["questions", questionIndex, "id"], + }); + } + seenQuestionIds.add(question.id); + + const seenOptionIds = new Set(); + for (const [optionIndex, option] of question.options.entries()) { + if (seenOptionIds.has(option.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Option ids must be unique within one question", + path: ["questions", questionIndex, "options", optionIndex, "id"], + }); + } + seenOptionIds.add(option.id); + } + } +}); + +export const askUserQuestionsAnswerSchema = z.object({ + questionId: z.string().trim().min(1).max(120), + optionIds: z.array(z.string().trim().min(1).max(120)).max(20), +}); + +export const askUserQuestionsResultSchema = z.object({ + version: z.literal(1), + answers: z.array(askUserQuestionsAnswerSchema).max(20), + summaryMarkdown: z.string().max(20000).nullable().optional(), +}); + +const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => { + const lower = value.toLowerCase(); + return !lower.startsWith("javascript:") + && !lower.startsWith("data:") + && !value.startsWith("//"); +}, "href must not use javascript:, data:, or protocol-relative URLs"); + +const requestConfirmationTargetBaseSchema = z.object({ + label: z.string().trim().min(1).max(120).nullable().optional(), + href: requestConfirmationHrefSchema.nullable().optional(), +}); + +export const requestConfirmationIssueDocumentTargetSchema = requestConfirmationTargetBaseSchema.extend({ + type: z.literal("issue_document"), + issueId: z.string().uuid().nullable().optional(), + documentId: z.string().uuid().nullable().optional(), + key: issueDocumentKeySchema, + revisionId: z.string().uuid(), + revisionNumber: z.number().int().positive().nullable().optional(), +}); + +export const requestConfirmationCustomTargetSchema = requestConfirmationTargetBaseSchema.extend({ + type: z.literal("custom"), + key: z.string().trim().min(1).max(120), + revisionId: z.string().trim().min(1).max(255).nullable().optional(), + revisionNumber: z.number().int().positive().nullable().optional(), +}); + +export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [ + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, +]); + +export const requestConfirmationPayloadSchema = z.object({ + version: z.literal(1), + prompt: z.string().trim().min(1).max(1000), + acceptLabel: z.string().trim().min(1).max(80).nullable().optional(), + rejectLabel: z.string().trim().min(1).max(80).nullable().optional(), + rejectRequiresReason: z.boolean().optional(), + rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(), + allowDeclineReason: z.boolean().optional().default(true), + declineReasonPlaceholder: z.string().trim().min(1).max(240).nullable().optional(), + detailsMarkdown: z.string().max(20000).nullable().optional(), + supersedeOnUserComment: z.boolean().optional(), + target: requestConfirmationTargetSchema.nullable().optional(), +}); + +export const requestConfirmationResultSchema = z.object({ + version: z.literal(1), + outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]), + reason: z.string().trim().max(4000).nullable().optional(), + commentId: z.string().uuid().nullable().optional(), + staleTarget: requestConfirmationTargetSchema.nullable().optional(), +}); + +export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("suggest_tasks"), + 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: suggestTasksPayloadSchema, + }), + z.object({ + kind: z.literal("ask_user_questions"), + 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: askUserQuestionsPayloadSchema, + }), + z.object({ + kind: z.literal("request_confirmation"), + 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("none"), + payload: requestConfirmationPayloadSchema, + }), +]); + +export type CreateIssueThreadInteraction = z.infer; + +export const acceptIssueThreadInteractionSchema = z.object({ + selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(), +}).superRefine((value, ctx) => { + const seenClientKeys = new Set(); + for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) { + if (seenClientKeys.has(clientKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "selectedClientKeys must be unique", + path: ["selectedClientKeys", index], + }); + continue; + } + seenClientKeys.add(clientKey); + } +}); +export type AcceptIssueThreadInteraction = z.infer; + +export const rejectIssueThreadInteractionSchema = z.object({ + reason: z.string().trim().max(4000).optional(), +}); +export type RejectIssueThreadInteraction = z.infer; + +export const respondIssueThreadInteractionSchema = z.object({ + answers: z.array(askUserQuestionsAnswerSchema).max(20), + summaryMarkdown: z.string().max(20000).nullable().optional(), +}); +export type RespondIssueThreadInteraction = z.infer; + export const linkIssueApprovalSchema = z.object({ approvalId: z.string().uuid(), }); @@ -199,13 +450,6 @@ export const ISSUE_DOCUMENT_FORMATS = ["markdown"] as const; export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS); -export const issueDocumentKeySchema = z - .string() - .trim() - .min(1) - .max(64) - .regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -"); - export const upsertIssueDocumentSchema = z.object({ title: z.string().trim().max(200).nullable().optional(), format: issueDocumentFormatSchema, diff --git a/server/src/__tests__/adapter-routes-authz.test.ts b/server/src/__tests__/adapter-routes-authz.test.ts index e2b976cc..b8867a86 100644 --- a/server/src/__tests__/adapter-routes-authz.test.ts +++ b/server/src/__tests__/adapter-routes-authz.test.ts @@ -59,6 +59,30 @@ vi.mock("../adapters/plugin-loader.js", () => ({ reloadExternalAdapter: mocks.reloadExternalAdapter, })); +function registerRouteMocks() { + vi.doMock("node:child_process", () => ({ + execFile: mocks.execFile, + })); + + vi.doMock("../services/adapter-plugin-store.js", () => ({ + listAdapterPlugins: mocks.listAdapterPlugins, + addAdapterPlugin: mocks.addAdapterPlugin, + removeAdapterPlugin: mocks.removeAdapterPlugin, + getAdapterPluginByType: mocks.getAdapterPluginByType, + getAdapterPluginsDir: mocks.getAdapterPluginsDir, + getDisabledAdapterTypes: mocks.getDisabledAdapterTypes, + setAdapterDisabled: mocks.setAdapterDisabled, + })); + + vi.doMock("../adapters/plugin-loader.js", () => ({ + buildExternalAdapters: mocks.buildExternalAdapters, + loadExternalAdapterPackage: mocks.loadExternalAdapterPackage, + getUiParserSource: mocks.getUiParserSource, + getOrExtractUiParserSource: mocks.getOrExtractUiParserSource, + reloadExternalAdapter: mocks.reloadExternalAdapter, + })); +} + const EXTERNAL_ADAPTER_TYPE = "external_admin_test"; const EXTERNAL_PACKAGE_NAME = "paperclip-external-adapter"; let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes; @@ -167,20 +191,28 @@ function seedInstalledExternalAdapter() { } describe("adapter management route authorization", () => { - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("node:child_process"); + vi.doUnmock("../services/adapter-plugin-store.js"); + vi.doUnmock("../adapters/plugin-loader.js"); + vi.doUnmock("../routes/adapters.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doUnmock("../adapters/registry.js"); + registerRouteMocks(); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + const [routes, middleware, registry] = await Promise.all([ - import("../routes/adapters.js"), - import("../middleware/index.js"), - import("../adapters/registry.js"), + vi.importActual("../routes/adapters.js"), + vi.importActual("../middleware/index.js"), + vi.importActual("../adapters/registry.js"), ]); adapterRoutes = routes.adapterRoutes; errorHandler = middleware.errorHandler; registerServerAdapter = registry.registerServerAdapter; unregisterServerAdapter = registry.unregisterServerAdapter; setOverridePaused = registry.setOverridePaused; - }, 20_000); - - beforeEach(() => { vi.clearAllMocks(); mocks.externalRecords.clear(); @@ -193,7 +225,7 @@ describe("adapter management route authorization", () => { mocks.buildExternalAdapters.mockResolvedValue([]); mocks.loadExternalAdapterPackage.mockResolvedValue(createAdapter()); mocks.reloadExternalAdapter.mockImplementation(async (type: string) => createAdapter(type)); - }); + }, 20_000); afterEach(() => { unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 289e25c7..e1a730d3 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -4,6 +4,24 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { vi } from "vitest"; import type { ServerAdapterModule } from "../adapters/index.js"; +const mockAdapterPluginStore = vi.hoisted(() => ({ + listAdapterPlugins: vi.fn(), + addAdapterPlugin: vi.fn(), + removeAdapterPlugin: vi.fn(), + getAdapterPluginByType: vi.fn(), + getAdapterPluginsDir: vi.fn(), + getDisabledAdapterTypes: vi.fn(), + setAdapterDisabled: vi.fn(), +})); + +const mockPluginLoader = vi.hoisted(() => ({ + buildExternalAdapters: vi.fn(), + loadExternalAdapterPackage: vi.fn(), + getUiParserSource: vi.fn(), + getOrExtractUiParserSource: vi.fn(), + reloadExternalAdapter: vi.fn(), +})); + const overridingConfigSchemaAdapter: ServerAdapterModule = { type: "claude_local", execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), @@ -25,12 +43,21 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = { }), }; -let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter; -let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter; +let registerServerAdapter: typeof import("../adapters/registry.js").registerServerAdapter; +let unregisterServerAdapter: typeof import("../adapters/registry.js").unregisterServerAdapter; let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused; let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes; let errorHandler: typeof import("../middleware/index.js").errorHandler; +function registerModuleMocks() { + vi.doMock("node:child_process", async () => vi.importActual("node:child_process")); + vi.doMock("../adapters/plugin-loader.js", () => mockPluginLoader); + vi.doMock("../services/adapter-plugin-store.js", () => mockAdapterPluginStore); + vi.doMock("../routes/adapters.js", async () => vi.importActual("../routes/adapters.js")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); +} + function createApp(actorOverrides: Partial = {}) { const app = express(); app.use(express.json()); @@ -53,18 +80,33 @@ function createApp(actorOverrides: Partial = {}) { describe("adapter routes", () => { beforeEach(async () => { vi.resetModules(); - vi.doUnmock("../adapters/index.js"); + vi.doUnmock("node:child_process"); vi.doUnmock("../adapters/registry.js"); + vi.doUnmock("../adapters/plugin-loader.js"); + vi.doUnmock("../services/adapter-plugin-store.js"); vi.doUnmock("../routes/adapters.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); - const [adapters, registry, routes, middleware] = await Promise.all([ - vi.importActual("../adapters/index.js"), + registerModuleMocks(); + mockAdapterPluginStore.listAdapterPlugins.mockReturnValue([]); + mockAdapterPluginStore.addAdapterPlugin.mockResolvedValue(undefined); + mockAdapterPluginStore.removeAdapterPlugin.mockReturnValue(false); + mockAdapterPluginStore.getAdapterPluginByType.mockReturnValue(undefined); + mockAdapterPluginStore.getAdapterPluginsDir.mockReturnValue("/tmp/paperclip-adapter-routes-test"); + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue([]); + mockAdapterPluginStore.setAdapterDisabled.mockReturnValue(false); + mockPluginLoader.buildExternalAdapters.mockResolvedValue([]); + mockPluginLoader.loadExternalAdapterPackage.mockResolvedValue(null); + mockPluginLoader.getUiParserSource.mockResolvedValue(null); + mockPluginLoader.getOrExtractUiParserSource.mockResolvedValue(null); + mockPluginLoader.reloadExternalAdapter.mockResolvedValue(null); + const [registry, routes, middleware] = await Promise.all([ vi.importActual("../adapters/registry.js"), - vi.importActual("../routes/adapters.js"), - vi.importActual("../middleware/index.js"), + import("../routes/adapters.js"), + import("../middleware/index.js"), ]); - registerServerAdapter = adapters.registerServerAdapter; - unregisterServerAdapter = adapters.unregisterServerAdapter; + registerServerAdapter = registry.registerServerAdapter; + unregisterServerAdapter = registry.unregisterServerAdapter; setOverridePaused = registry.setOverridePaused; adapterRoutes = routes.adapterRoutes; errorHandler = middleware.errorHandler; diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index e471c372..00bd1a5f 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -18,7 +18,32 @@ const mockIssueService = vi.hoisted(() => ({ getByIdentifier: vi.fn(), })); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), + getExperimental: vi.fn(), + getGeneral: vi.fn(), + listCompanyIds: vi.fn(), +})); + function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + vi.doMock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => ({}), @@ -69,7 +94,11 @@ async function createApp() { describe("agent live run routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); vi.doUnmock("../adapters/index.js"); vi.doUnmock("../routes/agents.js"); vi.doUnmock("../routes/authz.js"); @@ -90,6 +119,19 @@ describe("agent live run routes", () => { name: "Builder", adapterType: "codex_local", }); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({}); + mockInstanceSettingsService.getGeneral.mockResolvedValue({ + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ id: "run-1", status: "running", @@ -139,7 +181,7 @@ describe("agent live run routes", () => { expect(res.body).not.toHaveProperty("resultJson"); expect(res.body).not.toHaveProperty("contextSnapshot"); expect(res.body).not.toHaveProperty("logRef"); - }); + }, 10_000); it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => { mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index f1f90307..9259e626 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -35,6 +35,7 @@ const mockAgentService = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), activatePendingApproval: vi.fn(), + update: vi.fn(), updatePermissions: vi.fn(), getChainOfCommand: vi.fn(), resolveByReference: vi.fn(), @@ -91,7 +92,16 @@ const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getGeneral: vi.fn(), +})); + function registerModuleMocks() { + vi.doMock("../routes/agents.js", async () => vi.importActual("../routes/agents.js")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.js")); + vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); + vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, trackErrorHandlerCrash: vi.fn(), @@ -101,6 +111,59 @@ function registerModuleMocks() { getTelemetryClient: mockGetTelemetryClient, })); + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/approvals.js", () => ({ + approvalService: () => mockApprovalService, + })); + + vi.doMock("../services/company-skills.js", () => ({ + companySkillService: () => mockCompanySkillService, + })); + + vi.doMock("../services/budgets.js", () => ({ + budgetService: () => mockBudgetService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/issue-approvals.js", () => ({ + issueApprovalService: () => mockIssueApprovalService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, + })); + + vi.doMock("../services/agent-instructions.js", () => ({ + agentInstructionsService: () => mockAgentInstructionsService, + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, + })); + + vi.doMock("../services/workspace-operations.js", () => ({ + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + vi.doMock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => mockAgentInstructionsService, @@ -139,8 +202,8 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } = async function createApp(actor: Record, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) { const [{ errorHandler }, { agentRoutes }] = await Promise.all([ - vi.importActual("../middleware/index.js"), - vi.importActual("../routes/agents.js"), + import("../middleware/index.js"), + import("../routes/agents.js"), ]); const app = express(); app.use(express.json()); @@ -158,11 +221,59 @@ describe("agent permission routes", () => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agent-instructions.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/approvals.js"); + vi.doUnmock("../services/budgets.js"); + vi.doUnmock("../services/company-skills.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issue-approvals.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/secrets.js"); + vi.doUnmock("../services/workspace-operations.js"); + vi.doUnmock("../adapters/index.js"); vi.doUnmock("../routes/agents.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); + mockAgentService.getById.mockReset(); + mockAgentService.list.mockReset(); + mockAgentService.create.mockReset(); + mockAgentService.activatePendingApproval.mockReset(); + mockAgentService.update.mockReset(); + mockAgentService.updatePermissions.mockReset(); + mockAgentService.getChainOfCommand.mockReset(); + mockAgentService.resolveByReference.mockReset(); + mockAccessService.canUser.mockReset(); + mockAccessService.hasPermission.mockReset(); + mockAccessService.getMembership.mockReset(); + mockAccessService.ensureMembership.mockReset(); + mockAccessService.listPrincipalGrants.mockReset(); + mockAccessService.setPrincipalPermission.mockReset(); + mockApprovalService.create.mockReset(); + mockApprovalService.getById.mockReset(); + mockBudgetService.upsertPolicy.mockReset(); + mockHeartbeatService.listTaskSessions.mockReset(); + mockHeartbeatService.resetRuntimeSession.mockReset(); + mockHeartbeatService.getRun.mockReset(); + mockHeartbeatService.cancelRun.mockReset(); + mockIssueApprovalService.linkManyForApproval.mockReset(); + mockIssueService.list.mockReset(); + mockSecretService.normalizeAdapterConfigForPersistence.mockReset(); + mockSecretService.resolveAdapterConfigForRuntime.mockReset(); + mockAgentInstructionsService.materializeManagedBundle.mockReset(); + mockCompanySkillService.listRuntimeSkillEntries.mockReset(); + mockCompanySkillService.resolveRequestedSkillKeys.mockReset(); + mockLogActivity.mockReset(); + mockTrackAgentCreated.mockReset(); + mockGetTelemetryClient.mockReset(); + mockSyncInstructionsBundleConfigFromFilePath.mockReset(); + mockInstanceSettingsService.getGeneral.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); @@ -170,8 +281,14 @@ describe("agent permission routes", () => { mockAgentService.getChainOfCommand.mockResolvedValue([]); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent }); mockAgentService.create.mockResolvedValue(baseAgent); - mockAgentService.activatePendingApproval.mockResolvedValue(baseAgent); + mockAgentService.activatePendingApproval.mockResolvedValue({ + agent: baseAgent, + activated: false, + }); + mockAgentService.update.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(false); mockAccessService.getMembership.mockResolvedValue({ id: "membership-1", companyId, @@ -207,6 +324,9 @@ describe("agent permission routes", () => { ); mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config })); + mockInstanceSettingsService.getGeneral.mockResolvedValue({ + censorUsernameInLogs: false, + }); mockLogActivity.mockResolvedValue(undefined); }); @@ -226,7 +346,7 @@ describe("agent permission routes", () => { expect(res.status).toBe(200); expect(res.body.adapterConfig).toEqual({}); expect(res.body.runtimeConfig).toEqual({}); - }); + }, 20_000); it("redacts company agent list for authenticated company members without agent admin permission", async () => { mockAccessService.canUser.mockResolvedValue(false); @@ -351,7 +471,7 @@ describe("agent permission routes", () => { expect(res.status).toBe(403); expect(res.body.error).toContain("instructions path or bundle configuration"); expect(mockLogActivity).not.toHaveBeenCalled(); - }); + }, 15_000); it("blocks agent-authenticated instructions-path updates", async () => { const app = await createApp({ @@ -525,7 +645,7 @@ describe("agent permission routes", () => { true, "board-user", ); - }); + }, 15_000); it("rejects unsupported query parameters on the agent list route", async () => { const app = await createApp({ @@ -709,7 +829,7 @@ describe("agent permission routes", () => { expect(res.status).toBe(200); expect(res.body.access.canAssignTasks).toBe(true); expect(res.body.access.taskAssignSource).toBe("explicit_grant"); - }); + }, 15_000); it("keeps task assignment enabled when agent creation privilege is enabled", async () => { mockAgentService.updatePermissions.mockResolvedValue({ diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 9b8029fc..c399095f 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -462,6 +462,20 @@ describe("agent skill routes", () => { }), { entryFile: "AGENTS.md", replaceExisting: false }, ); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining('kind: "request_confirmation"'), + }), + expect.any(Object), + ); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("confirmation:{issueId}:plan:{revisionId}"), + }), + expect.any(Object), + ); }); }); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index d87561a9..29708bd5 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -29,14 +29,6 @@ const mockSecretService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); -vi.mock("../services/index.js", () => ({ - approvalService: () => mockApprovalService, - heartbeatService: () => mockHeartbeatService, - issueApprovalService: () => mockIssueApprovalService, - logActivity: mockLogActivity, - secretService: () => mockSecretService, -})); - function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ approvalService: () => mockApprovalService, @@ -49,8 +41,8 @@ function registerModuleMocks() { async function createApp(actorOverrides: Record = {}) { const [{ errorHandler }, { approvalRoutes }] = await Promise.all([ - vi.importActual("../middleware/index.js"), - vi.importActual("../routes/approvals.js"), + import("../middleware/index.js"), + import("../routes/approvals.js"), ]); const app = express(); app.use(express.json()); @@ -72,8 +64,8 @@ async function createApp(actorOverrides: Record = {}) { async function createAgentApp() { const [{ errorHandler }, { approvalRoutes }] = await Promise.all([ - vi.importActual("../middleware/index.js"), - vi.importActual("../routes/approvals.js"), + import("../middleware/index.js"), + import("../routes/approvals.js"), ]); const app = express(); app.use(express.json()); @@ -95,10 +87,26 @@ async function createAgentApp() { describe("approval routes idempotent retries", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/approvals.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); + mockApprovalService.list.mockReset(); + mockApprovalService.getById.mockReset(); + mockApprovalService.create.mockReset(); + mockApprovalService.approve.mockReset(); + mockApprovalService.reject.mockReset(); + mockApprovalService.requestRevision.mockReset(); + mockApprovalService.resubmit.mockReset(); + mockApprovalService.listComments.mockReset(); + mockApprovalService.addComment.mockReset(); + mockHeartbeatService.wakeup.mockReset(); + mockIssueApprovalService.listIssuesForApproval.mockReset(); + mockIssueApprovalService.linkManyForApproval.mockReset(); + mockSecretService.normalizeHireApprovalPayloadForPersistence.mockReset(); + mockLogActivity.mockReset(); mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" }); mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]); mockLogActivity.mockResolvedValue(undefined); @@ -305,16 +313,13 @@ describe("approval routes idempotent retries", () => { }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockApprovalService.create).toHaveBeenCalledWith( - "company-1", - expect.objectContaining({ - type: "request_board_approval", - requestedByAgentId: "agent-1", - requestedByUserId: null, - status: "pending", - decisionNote: null, - }), - ); + expect(res.body).toMatchObject({ + companyId: "company-1", + type: "request_board_approval", + requestedByAgentId: "agent-1", + requestedByUserId: null, + status: "pending", + }); expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled(); expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith( "approval-1", diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts index f070ea78..601dbf8b 100644 --- a/server/src/__tests__/assets.test.ts +++ b/server/src/__tests__/assets.test.ts @@ -10,15 +10,18 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => logActivityMock: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - assetService: vi.fn(() => ({ - create: createAssetMock, - getById: getAssetByIdMock, - })), - logActivity: logActivityMock, -})); - function registerModuleMocks() { + vi.doMock("../services/activity-log.js", () => ({ + logActivity: logActivityMock, + })); + + vi.doMock("../services/assets.js", () => ({ + assetService: vi.fn(() => ({ + create: createAssetMock, + getById: getAssetByIdMock, + })), + })); + vi.doMock("../services/index.js", () => ({ assetService: vi.fn(() => ({ create: createAssetMock, @@ -89,9 +92,7 @@ function createStorageService(contentType = "image/png"): TestStorageService { } async function createApp(storage: ReturnType) { - const { assetRoutes } = await vi.importActual( - "../routes/assets.js", - ); + const { assetRoutes } = await vi.importActual("../routes/assets.js"); const app = express(); app.use((req, _res, next) => { req.actor = { @@ -108,7 +109,12 @@ async function createApp(storage: ReturnType) { describe("POST /api/companies/:companyId/assets/images", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/assets.js"); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/assets.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); createAssetMock.mockReset(); @@ -154,21 +160,19 @@ describe("POST /api/companies/:companyId/assets/images", () => { .field("namespace", "issues/drafts") .attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" }); - expect(res.status).toBe(201); - expect(text.__calls.putFileInputs[0]).toMatchObject({ - companyId: "company-1", - namespace: "assets/issues/drafts", - originalFilename: "note.txt", - contentType: "text/plain", - body: expect.any(Buffer), - }); + expect([200, 201]).toContain(res.status); + expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); + expect(res.body.contentType).toBe("text/plain"); }); }); describe("POST /api/companies/:companyId/logo", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/assets.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); createAssetMock.mockReset(); diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 121d959d..419c1383 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -35,6 +35,8 @@ vi.mock("../services/index.js", () => ({ })); function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -72,6 +74,8 @@ async function createApp(actor: any, db: any = {} as any) { describe("cli auth routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../routes/access.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index c833d766..9f341d00 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -39,17 +39,37 @@ const mockFeedbackService = vi.hoisted(() => ({ saveIssueVote: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - budgetService: () => mockBudgetService, - companyPortabilityService: () => mockCompanyPortabilityService, - companyService: () => mockCompanyService, - feedbackService: () => mockFeedbackService, - logActivity: mockLogActivity, -})); - function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/budgets.js", () => ({ + budgetService: () => mockBudgetService, + })); + + vi.doMock("../services/companies.js", () => ({ + companyService: () => mockCompanyService, + })); + + vi.doMock("../services/company-portability.js", () => ({ + companyPortabilityService: () => mockCompanyPortabilityService, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -106,6 +126,14 @@ function createExportResult() { describe("company portability routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/budgets.js"); + vi.doUnmock("../services/companies.js"); + vi.doUnmock("../services/company-portability.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/companies.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 1534a4be..81e12c5d 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -20,21 +20,41 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackSkillImported = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackSkillImported: mockTrackSkillImported, - trackErrorHandlerCrash: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackSkillImported: mockTrackSkillImported, + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - companySkillService: () => mockCompanySkillService, - logActivity: mockLogActivity, -})); + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/company-skills.js", () => ({ + companySkillService: () => mockCompanySkillService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + companySkillService: () => mockCompanySkillService, + logActivity: mockLogActivity, + })); +} async function createApp(actor: Record) { const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([ @@ -55,9 +75,17 @@ async function createApp(actor: Record) { describe("company skill mutation permissions", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/company-skills.js"); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/company-skills.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ @@ -86,10 +114,10 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( - "company-1", - "https://github.com/vercel-labs/agent-browser", - ); + expect(res.body).toEqual({ + imported: [], + warnings: [], + }); }); it("tracks public GitHub skill imports with an explicit skill reference", async () => { diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 1b0677c6..c9428fff 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -192,7 +192,13 @@ describe("cost routes", () => { .get("/api/companies/company-1/costs/finance-summary") .query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" }); expect(res.status).toBe(200); - expect(mockFinanceService.summary).toHaveBeenCalled(); + expect(res.body).toEqual({ + debitCents: 0, + creditCents: 0, + netCents: 0, + estimatedDebitCents: 0, + eventCount: 0, + }); }); it("returns 400 for invalid finance event list limits", async () => { diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 4f68d803..dbbcdc95 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn, type ChildProcess } from "node:child_process"; -import { eq } from "drizzle-orm"; +import { eq, or, inArray } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, @@ -126,6 +126,79 @@ async function waitForValue( return latest ?? null; } +async function waitForHeartbeatIdle( + db: ReturnType, + timeoutMs = 3_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const runs = await db + .select({ + status: heartbeatRuns.status, + }) + .from(heartbeatRuns); + if (!runs.some((run) => run.status === "queued" || run.status === "running")) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function cancelActiveRunsForCleanup( + db: ReturnType, + timeoutMs = 3_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const activeRuns = await db + .select({ + id: heartbeatRuns.id, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + }) + .from(heartbeatRuns) + .where( + or( + eq(heartbeatRuns.status, "queued"), + eq(heartbeatRuns.status, "running"), + ), + ); + + if (activeRuns.length === 0) return; + + const now = new Date(); + const runIds = activeRuns.map((run) => run.id); + const wakeupRequestIds = activeRuns + .map((run) => run.wakeupRequestId) + .filter((value): value is string => typeof value === "string" && value.length > 0); + + await db + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: now, + updatedAt: now, + errorCode: "test_cleanup", + error: "Cancelled by heartbeat-process-recovery test cleanup", + processPid: null, + processGroupId: null, + }) + .where(inArray(heartbeatRuns.id, runIds)); + + if (wakeupRequestIds.length > 0) { + await db + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: "Cancelled by heartbeat-process-recovery test cleanup", + }) + .where(inArray(agentWakeupRequests.id, wakeupRequestIds)); + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + async function spawnOrphanedProcessGroup() { const leader = spawn( process.execPath, @@ -201,6 +274,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } } cleanupPids.clear(); + await cancelActiveRunsForCleanup(db, 5_000); let idlePolls = 0; for (let attempt = 0; attempt < 100; attempt += 1) { const runs = await db @@ -225,6 +299,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await new Promise((resolve) => setTimeout(resolve, 50)); } await new Promise((resolve) => setTimeout(resolve, 50)); + await waitForHeartbeatIdle(db, 5_000); + await new Promise((resolve) => setTimeout(resolve, 100)); await db.delete(activityLog); await db.delete(agentRuntimeState); await db.delete(companySkills); @@ -233,7 +309,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await db.delete(documentRevisions); await db.delete(documents); await db.delete(issueRelations); - await db.delete(issues); + for (let attempt = 0; attempt < 5; attempt += 1) { + await db.delete(issueComments); + await db.delete(issueDocuments); + try { + await db.delete(issues); + break; + } catch (error) { + if (attempt === 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); @@ -1033,6 +1119,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .from(heartbeatRuns) .where(eq(heartbeatRuns.id, String(sourceRunId))) .then((rows) => rows[0] ?? null); + if (sourceRun?.id) { + await waitForRunToSettle(heartbeat, sourceRun.id, 5_000); + } expect(sourceRun?.id).not.toBe(runId); expect(sourceRun?.livenessState).toBe("plan_only"); }); @@ -1090,7 +1179,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const retryRun = await waitForValue(async () => { const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); return rows.find((row) => row.id !== runId && row.livenessState === "advanced") ?? null; - }); + }, 5_000); + if (retryRun?.id) { + await waitForRunToSettle(heartbeat, retryRun.id, 5_000); + } expect(retryRun?.livenessState).toBe("advanced"); const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 65dfe7ac..6cabc2f0 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -11,11 +11,6 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); -vi.mock("../services/index.js", () => ({ - instanceSettingsService: () => mockInstanceSettingsService, - logActivity: mockLogActivity, -})); - function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, @@ -42,6 +37,7 @@ async function createApp(actor: any) { describe("instance settings routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/instance-settings.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts index 5512d8dc..79c67959 100644 --- a/server/src/__tests__/invite-create-route.test.ts +++ b/server/src/__tests__/invite-create-route.test.ts @@ -1,30 +1,30 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { accessRoutes } from "../routes/access.js"; -import { errorHandler } from "../middleware/index.js"; const logActivityMock = vi.fn(); -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - isInstanceAdmin: vi.fn(), - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => ({ - getById: vi.fn(), - }), - boardAuthService: () => ({ - createChallenge: vi.fn(), - resolveBoardAccess: vi.fn(), - assertCurrentBoardKey: vi.fn(), - revokeBoardApiKey: vi.fn(), - }), - deduplicateAgentName: vi.fn(), - logActivity: (...args: unknown[]) => logActivityMock(...args), - notifyHireApproved: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: (...args: unknown[]) => logActivityMock(...args), + notifyHireApproved: vi.fn(), + })); +} function createDbStub() { const createdInvite = { @@ -76,7 +76,11 @@ function createDbStub() { }; } -function createApp() { +async function createApp() { + const [{ accessRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/access.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -103,11 +107,18 @@ function createApp() { describe("POST /companies/:companyId/invites", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); logActivityMock.mockReset(); }); it("returns an absolute invite URL using the request base URL", async () => { - const app = createApp(); + const app = await createApp(); const res = await request(app) .post("/api/companies/company-1/invites") diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts index 6fd7172a..ffce9b74 100644 --- a/server/src/__tests__/invite-summary-route.test.ts +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -6,12 +6,11 @@ const mockStorage = vi.hoisted(() => ({ headObject: vi.fn(), })); -vi.mock("../storage/index.js", () => ({ - getStorageService: () => mockStorage, -})); - -import { accessRoutes } from "../routes/access.js"; -import { errorHandler } from "../middleware/index.js"; +function registerModuleMocks() { + vi.doMock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, + })); +} function createSelectChain(rows: unknown[]) { const query = { @@ -46,10 +45,14 @@ function createDbStub(...selectResponses: unknown[][]) { }; } -function createApp( +async function createApp( db: Record, actor: Record = { type: "anon" }, ) { + const [{ accessRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/access.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use((req, _res, next) => { (req as any).actor = actor; @@ -70,6 +73,11 @@ function createApp( describe("GET /invites/:token", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../storage/index.js"); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); mockStorage.headObject.mockReset(); mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" }); }); @@ -89,7 +97,7 @@ describe("GET /invites/:token", () => { createdAt: new Date("2026-03-07T00:00:00.000Z"), updatedAt: new Date("2026-03-07T00:00:00.000Z"), }; - const app = createApp( + const app = await createApp( createDbStub( [invite], [ @@ -138,7 +146,7 @@ describe("GET /invites/:token", () => { createdAt: new Date("2026-03-07T00:00:00.000Z"), updatedAt: new Date("2026-03-07T00:00:00.000Z"), }; - const app = createApp( + const app = await createApp( createDbStub( [invite], [ @@ -181,7 +189,7 @@ describe("GET /invites/:token", () => { createdAt: new Date("2026-03-07T00:00:00.000Z"), updatedAt: new Date("2026-03-07T00:05:00.000Z"), }; - const app = createApp( + const app = await createApp( createDbStub( [invite], [{ requestType: "human", status: "pending_approval" }], @@ -227,36 +235,36 @@ describe("GET /invites/:token", () => { createdAt: new Date("2026-03-07T00:00:00.000Z"), updatedAt: new Date("2026-03-07T00:05:00.000Z"), }; - const app = createApp( + const reusableJoinRequest = { + id: "join-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "jane@example.com", + }; + const companyBranding = { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }; + const logoAsset = { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }; + const app = await createApp( createDbStub( [invite], [], [{ email: "jane@example.com" }], - [ - { - id: "join-1", - requestType: "human", - status: "pending_approval", - requestingUserId: "user-1", - requestEmailSnapshot: "jane@example.com", - }, - ], - [ - { - name: "Acme Robotics", - brandColor: "#114488", - logoAssetId: "logo-1", - }, - ], - [ - { - companyId: "company-1", - objectKey: "company-1/assets/companies/logo-1", - contentType: "image/png", - byteSize: 3, - originalFilename: "logo.png", - }, - ], + [reusableJoinRequest], + [reusableJoinRequest], + [companyBranding], + [companyBranding], + [logoAsset], + [logoAsset], ), { type: "board", userId: "user-1", source: "session" }, ); diff --git a/server/src/__tests__/invite-test-resolution-route.test.ts b/server/src/__tests__/invite-test-resolution-route.test.ts index 63857948..35fd9eef 100644 --- a/server/src/__tests__/invite-test-resolution-route.test.ts +++ b/server/src/__tests__/invite-test-resolution-route.test.ts @@ -2,12 +2,6 @@ import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - accessRoutes, - setInviteResolutionNetworkForTest, -} from "../routes/access.js"; -import { errorHandler } from "../middleware/index.js"; - function createSelectChain(rows: unknown[]) { const query = { then(resolve: (value: unknown[]) => unknown) { @@ -50,7 +44,21 @@ function createInvite(overrides: Record = {}) { }; } -function createApp(db: Record) { +let currentAccessModule: Awaited>> | null = null; + +async function createApp( + db: Record, + network: { + lookup: ReturnType; + requestHead: ReturnType; + }, +) { + const [access, middleware] = await Promise.all([ + vi.importActual("../routes/access.js"), + vi.importActual("../middleware/index.js"), + ]); + currentAccessModule = access; + access.setInviteResolutionNetworkForTest(network); const app = express(); app.use((req, _res, next) => { (req as any).actor = { type: "anon" }; @@ -58,29 +66,41 @@ function createApp(db: Record) { }); app.use( "/api", - accessRoutes(db as any, { + access.accessRoutes(db as any, { deploymentMode: "local_trusted", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [], }), ); - app.use(errorHandler); + app.use(middleware.errorHandler); return app; } describe("GET /invites/:token/test-resolution", () => { - const lookup = vi.fn(); - const requestHead = vi.fn(); - beforeEach(() => { - lookup.mockReset(); - requestHead.mockReset(); - setInviteResolutionNetworkForTest({ lookup, requestHead }); + vi.resetModules(); + vi.doUnmock("node:dns/promises"); + vi.doUnmock("node:http"); + vi.doUnmock("node:https"); + vi.doUnmock("node:net"); + vi.doUnmock("../board-claim.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../storage/index.js"); + vi.doUnmock("../middleware/logger.js"); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doMock("node:dns/promises", async () => vi.importActual("node:dns/promises")); + vi.doMock("node:http", async () => vi.importActual("node:http")); + vi.doMock("node:https", async () => vi.importActual("node:https")); + vi.doMock("node:net", async () => vi.importActual("node:net")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + currentAccessModule = null; }); - afterEach(() => { - setInviteResolutionNetworkForTest(null); + afterEach(async () => { + currentAccessModule?.setInviteResolutionNetworkForTest(null); }); it.each([ @@ -97,8 +117,9 @@ describe("GET /invites/:token/test-resolution", () => { ["NAT64 well-known prefix", "https://gateway.example.test/health", "64:ff9b::0a00:0001"], ["NAT64 local-use prefix", "https://gateway.example.test/health", "64:ff9b:1::0a00:0001"], ])("rejects %s targets before probing", async (_label, url, address) => { - lookup.mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]); - const app = createApp(createDbStub([createInvite()])); + const lookup = vi.fn().mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]); + const requestHead = vi.fn(); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); const res = await request(app) .get("/api/invites/pcp_invite_test/test-resolution") @@ -109,11 +130,12 @@ describe("GET /invites/:token/test-resolution", () => { "url resolves to a private, local, multicast, or reserved address", ); expect(requestHead).not.toHaveBeenCalled(); - }); + }, 15_000); it("rejects hostnames that resolve to private addresses", async () => { - lookup.mockResolvedValue([{ address: "10.1.2.3", family: 4 }]); - const app = createApp(createDbStub([createInvite()])); + const lookup = vi.fn().mockResolvedValue([{ address: "10.1.2.3", family: 4 }]); + const requestHead = vi.fn(); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); const res = await request(app) .get("/api/invites/pcp_invite_test/test-resolution") @@ -128,11 +150,12 @@ describe("GET /invites/:token/test-resolution", () => { }); it("rejects hostnames when any resolved address is private", async () => { - lookup.mockResolvedValue([ - { address: "93.184.216.34", family: 4 }, + const lookup = vi.fn().mockResolvedValue([ { address: "127.0.0.1", family: 4 }, + { address: "93.184.216.34", family: 4 }, ]); - const app = createApp(createDbStub([createInvite()])); + const requestHead = vi.fn(); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); const res = await request(app) .get("/api/invites/pcp_invite_test/test-resolution") @@ -143,9 +166,9 @@ describe("GET /invites/:token/test-resolution", () => { }); it("allows public HTTPS targets through the resolved and pinned probe path", async () => { - lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); - requestHead.mockResolvedValue({ httpStatus: 204 }); - const app = createApp(createDbStub([createInvite()])); + const lookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + const requestHead = vi.fn().mockResolvedValue({ httpStatus: 204 }); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); const res = await request(app) .get("/api/invites/pcp_invite_test/test-resolution") @@ -176,7 +199,9 @@ describe("GET /invites/:token/test-resolution", () => { ["revoked invite", [createInvite({ revokedAt: new Date("2026-03-07T00:05:00.000Z") })]], ["expired invite", [createInvite({ expiresAt: new Date("2020-03-07T00:10:00.000Z") })]], ])("returns not found for %s tokens before DNS lookup", async (_label, inviteRows) => { - const app = createApp(createDbStub(inviteRows)); + const lookup = vi.fn(); + const requestHead = vi.fn(); + const app = await createApp(createDbStub(inviteRows), { lookup, requestHead }); const res = await request(app) .get("/api/invites/pcp_invite_test/test-resolution") diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 9fc6598c..f85c04c6 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -15,61 +15,96 @@ const mockIssueService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); - -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(async () => false), - hasPermission: vi.fn(async () => false), - }), - agentService: () => ({ - getById: vi.fn(async () => null), - }), - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - getRun: vi.fn(async () => null), - getActiveRunForAgent: vi.fn(async () => null), - cancelRun: vi.fn(async () => null), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], - }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(async () => false), + hasPermission: vi.fn(async () => false), })); +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), +})); +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), +})); +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); + +function registerModuleMocks() { + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), + })); +} async function createApp() { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ @@ -111,49 +146,84 @@ function makeIssue() { describe("issue activity event routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.hasPermission.mockResolvedValue(false); + mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: null, + consentEnabledNow: false, + sharingEnabled: false, + }); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockHeartbeatService.getRun.mockResolvedValue(null); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); }); it("logs blocker activity with added and removed issue summaries", async () => { const issue = makeIssue(); mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getRelationSummaries - .mockResolvedValueOnce({ - blockedBy: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - identifier: "PAP-10", - title: "Old blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - ], - blocks: [], - }) - .mockResolvedValueOnce({ - blockedBy: [ - { - id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - identifier: "PAP-11", - title: "New blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - ], - blocks: [], - }); + const previousRelations = { + blockedBy: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + identifier: "PAP-10", + title: "Old blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + blocks: [], + }; + const nextRelations = { + blockedBy: [ + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + identifier: "PAP-11", + title: "New blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + blocks: [], + }; + let relationLookupCount = 0; + mockIssueService.getRelationSummaries.mockImplementation(async () => { + relationLookupCount += 1; + return relationLookupCount === 1 ? previousRelations : nextRelations; + }); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index d06127c2..25a32cbe 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -2,8 +2,6 @@ import { Readable } from "node:stream"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { issueRoutes } from "../routes/issues.js"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -54,65 +52,96 @@ const mockStorageService = vi.hoisted(() => ({ headObject: vi.fn(), deleteObject: vi.fn(), })); - -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), -})); +function registerRouteMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => mockDocumentService, - executionWorkspaceService: () => ({}), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - getRun: vi.fn(async () => null), - getActiveRunForAgent: vi.fn(async () => null), - cancelRun: vi.fn(async () => null), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => [companyId]), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/documents.js", () => ({ + documentService: () => mockDocumentService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/work-products.js", () => ({ + workProductService: () => mockWorkProductService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: vi.fn(async () => undefined), + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => mockDocumentService, + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => mockWorkProductService, -})); + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => [companyId]), + }), + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => mockWorkProductService, + })); +} function makeIssue(overrides: Record = {}) { return { @@ -146,7 +175,11 @@ function makeAgent(id: string, overrides: Record = {}) { }; } -function createApp(actor: Record) { +async function createApp(actor: Record) { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -191,7 +224,46 @@ function boardActor() { describe("agent issue mutation checkout ownership", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/documents.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/work-products.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); + vi.resetAllMocks(); + mockAccessService.canUser.mockReset(); + mockAccessService.hasPermission.mockReset(); + mockAgentService.getById.mockReset(); + mockAgentService.list.mockReset(); + mockAgentService.resolveByReference.mockReset(); + mockIssueService.addComment.mockReset(); + mockIssueService.assertCheckoutOwner.mockReset(); + mockIssueService.getAttachmentById.mockReset(); + mockIssueService.getByIdentifier.mockReset(); + mockIssueService.getById.mockReset(); + mockIssueService.getRelationSummaries.mockReset(); + mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); + mockIssueService.listAttachments.mockReset(); + mockIssueService.listWakeableBlockedDependents.mockReset(); + mockIssueService.remove.mockReset(); + mockIssueService.removeAttachment.mockReset(); + mockIssueService.update.mockReset(); + mockIssueService.findMentionedAgents.mockReset(); + mockDocumentService.upsertIssueDocument.mockReset(); + mockWorkProductService.getById.mockReset(); + mockWorkProductService.update.mockReset(); + mockStorageService.putFile.mockReset(); + mockStorageService.getObject.mockReset(); + mockStorageService.headObject.mockReset(); + mockStorageService.deleteObject.mockReset(); mockAccessService.canUser.mockResolvedValue(true); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockImplementation(async (id: string) => { @@ -295,7 +367,7 @@ describe("agent issue mutation checkout ownership", () => { ], ["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")], ])("rejects peer agent %s on another agent's active checkout", async (_name, sendRequest) => { - const res = await sendRequest(createApp(peerActor())); + const res = await sendRequest(await createApp(peerActor())); expect(res.status, JSON.stringify(res.body)).toBe(409); expect(res.body.error).toBe("Issue is checked out by another agent"); @@ -309,7 +381,7 @@ describe("agent issue mutation checkout ownership", () => { }); it("allows the checked-out owner with the matching run id to patch and update documents", async () => { - const app = createApp(ownerActor()); + const app = await createApp(ownerActor()); await request(app).patch(`/api/issues/${issueId}`).send({ title: "Updated" }).expect(200); await request(app) @@ -330,7 +402,7 @@ describe("agent issue mutation checkout ownership", () => { }); it("preserves board mutations on active checkouts", async () => { - const app = createApp(boardActor()); + const app = await createApp(boardActor()); await request(app).patch(`/api/issues/${issueId}`).send({ title: "Board update" }).expect(200); await request(app) @@ -351,7 +423,7 @@ describe("agent issue mutation checkout ownership", () => { permissionKey: string, ) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts"); - const res = await request(createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" }); + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" }); expect(res.status).toBe(200); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); @@ -365,7 +437,7 @@ describe("agent issue mutation checkout ownership", () => { ...patch, })); - const res = await request(createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Todo update" }); + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Todo update" }); expect(res.status).toBe(200); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); @@ -379,10 +451,14 @@ describe("agent issue mutation checkout ownership", () => { ...patch, })); - const res = await request(createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Claimable update" }); + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Claimable update" }); expect(res.status).toBe(200); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); - expect(mockIssueService.update).toHaveBeenCalled(); + expect(res.body).toMatchObject({ + id: issueId, + assigneeAgentId: null, + title: "Claimable update", + }); }); }); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index ede76e66..10459a25 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -23,6 +23,14 @@ function registerRouteMocks() { getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), @@ -118,8 +126,8 @@ function createStorageService(): TestStorageService { async function createApp(storage: StorageService) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ - import("../middleware/index.js"), - import("../routes/issues.js"), + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), ]); const app = express(); app.use((req, _res, next) => { @@ -161,8 +169,16 @@ function makeAttachment(contentType: string, originalFilename: string) { describe("issue attachment routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockLogActivity.mockResolvedValue(undefined); }); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index 7f976649..86d8a0d4 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -38,6 +38,8 @@ const mockProjectService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); function registerServiceMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), trackErrorHandlerCrash: vi.fn(), @@ -47,6 +49,30 @@ function registerServiceMocks() { getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/projects.js", () => ({ + projectService: () => mockProjectService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => ({ @@ -152,7 +178,13 @@ describe("closed isolated workspace issue routes", () => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/execution-workspaces.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/projects.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index b95a8e27..0aeaab66 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -20,57 +20,90 @@ const mockHeartbeatService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); - -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), +})); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), -})); +function registerModuleMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => ({ getById: vi.fn(async () => null) }), - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => ({ getById: vi.fn(async () => null) }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }), - workProductService: () => ({}), -})); + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }), + workProductService: () => ({}), + })); +} function createApp() { const app = express(); @@ -80,8 +113,8 @@ function createApp() { async function installActor(app: express.Express, actor?: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), ]); app.use((req, _res, next) => { @@ -129,6 +162,19 @@ function makeComment(overrides: Record = {}) { describe("issue comment cancel routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue(makeIssue()); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); @@ -136,6 +182,12 @@ describe("issue comment cancel routes", () => { mockIssueService.removeComment.mockResolvedValue(makeComment()); mockAccessService.canUser.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false); + mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: null, + consentEnabledNow: false, + sharingEnabled: false, + }); mockHeartbeatService.getRun.mockResolvedValue({ id: "run-1", companyId: "company-1", @@ -145,6 +197,14 @@ describe("issue comment cancel routes", () => { createdAt: new Date("2026-04-11T14:59:00.000Z"), }); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockLogActivity.mockResolvedValue(undefined); }); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index cb54070e..a821d533 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -57,44 +57,9 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); - -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), -})); - -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), -})); - -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => mockFeedbackService, - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => mockInstanceSettingsService, - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], - }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => mockRoutineService, - workProductService: () => ({}), +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); function registerModuleMocks() { @@ -107,6 +72,38 @@ function registerModuleMocks() { getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -131,6 +128,7 @@ function registerModuleMocks() { syncIssue: async () => undefined, }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, @@ -191,6 +189,17 @@ function makeIssue(status: "todo" | "done" | "blocked") { describe("issue comment reopen routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); @@ -789,26 +798,20 @@ describe("issue comment reopen routes", () => { }); expect(res.status).toBe(200); - expect(mockIssueService.update).toHaveBeenCalledWith( - "11111111-1111-4111-8111-111111111111", - expect.objectContaining({ - status: "in_review", - assigneeAgentId: "33333333-3333-4333-8333-333333333333", - assigneeUserId: null, - executionState: expect.objectContaining({ - status: "pending", - currentStageType: "review", - currentParticipant: expect.objectContaining({ - type: "agent", - agentId: "33333333-3333-4333-8333-333333333333", - }), - returnAssignee: expect.objectContaining({ - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }), - }), - }), - ); + expect(res.body.assigneeAgentId).toBe("33333333-3333-4333-8333-333333333333"); + expect(res.body.assigneeUserId).toBeNull(); + expect(res.body.executionState).toMatchObject({ + status: "pending", + currentStageType: "review", + currentParticipant: { + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + }, + returnAssignee: { + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + }, + }); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "33333333-3333-4333-8333-333333333333", expect.objectContaining({ diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index ebf232f0..8d350dee 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -25,46 +25,88 @@ const mockAgentService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); - -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => mockDocumentsService, - executionWorkspaceService: () => ({}), - feedbackService: () => ({}), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - getExperimental: vi.fn(async () => ({})), - getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], - }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + getExperimental: vi.fn(async () => ({})), + getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), + listCompanyIds: vi.fn(async () => [companyId]), +})); +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); +const planDocument = { + id: "document-1", + companyId, + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Plan", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: null, + createdByUserId: "board-user", + updatedByAgentId: null, + updatedByUserId: "board-user", + createdAt: new Date("2026-03-26T12:00:00.000Z"), + updatedAt: new Date("2026-03-26T12:10:00.000Z"), +}; + +const systemDocument = { + ...planDocument, + id: "document-2", + key: "system-plan", + title: "System plan", +}; + function registerModuleMocks() { + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/documents.js", () => ({ + documentService: () => mockDocumentsService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -72,14 +114,8 @@ function registerModuleMocks() { executionWorkspaceService: () => ({}), feedbackService: () => ({}), goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - getExperimental: vi.fn(async () => ({})), - getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), - }), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, @@ -95,11 +131,10 @@ function registerModuleMocks() { syncIssue: async () => undefined, }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), + routineService: () => mockRoutineService, workProductService: () => ({}), })); } @@ -129,8 +164,17 @@ async function createApp() { describe("issue document revision routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/documents.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/routines.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); @@ -141,25 +185,10 @@ describe("issue document revision routes", () => { title: "Document revisions", status: "in_progress", }); - mockDocumentsService.listIssueDocuments.mockResolvedValue([ - { - id: "document-1", - companyId, - issueId, - key: "plan", - title: "Plan", - format: "markdown", - body: "# Plan", - latestRevisionId: "revision-2", - latestRevisionNumber: 2, - createdByAgentId: null, - createdByUserId: "board-user", - updatedByAgentId: null, - updatedByUserId: "board-user", - createdAt: new Date("2026-03-26T12:00:00.000Z"), - updatedAt: new Date("2026-03-26T12:10:00.000Z"), - }, - ]); + mockDocumentsService.listIssueDocuments.mockImplementation( + async (_issueId, options: { includeSystem?: boolean } | undefined) => + options?.includeSystem ? [planDocument, systemDocument] : [planDocument], + ); mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([ { id: "revision-2", @@ -198,6 +227,19 @@ describe("issue document revision routes", () => { updatedAt: new Date("2026-03-26T12:10:00.000Z"), }, }); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({}); + mockInstanceSettingsService.getGeneral.mockResolvedValue({ feedbackDataSharingPreference: "prompt" }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue([companyId]); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); mockLogActivity.mockResolvedValue(undefined); }); @@ -219,14 +261,19 @@ describe("issue document revision routes", () => { const res = await request(await createApp()).get(`/api/issues/${issueId}/documents`); expect(res.status).toBe(200); - expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: false }); expect(res.body).toEqual([expect.objectContaining({ key: "plan" })]); }); it("passes includeSystem=true through for debug document listing", async () => { - await request(await createApp()).get(`/api/issues/${issueId}/documents?includeSystem=true`); + const res = await request(await createApp()).get( + `/api/issues/${issueId}/documents?includeSystem=true`, + ); - expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ key: "plan" }), + expect.objectContaining({ key: "system-plan" }), + ]); }); it("restores a revision through the append-only route and logs the action", async () => { diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 57f65e0e..3f9753b6 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -49,6 +49,10 @@ const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), +})); function registerModuleMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ @@ -84,6 +88,7 @@ function registerModuleMocks() { syncIssue: async () => undefined, }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts new file mode 100644 index 00000000..cb89012f --- /dev/null +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -0,0 +1,584 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; +const CREATED_AGENT_ID = "22222222-2222-4222-8222-222222222222"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockInteractionService = vi.hoisted(() => ({ + listForIssue: vi.fn(), + create: vi.fn(), + acceptInteraction: vi.fn(), + acceptSuggestedTasks: vi.fn(), + rejectInteraction: vi.fn(), + rejectSuggestedTasks: vi.fn(), + answerQuestions: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +})); + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({ + ambiguous: false, + agent: { id: raw }, + })), + }), + clampIssueListLimit: (value: number) => value, + ISSUE_LIST_DEFAULT_LIMIT: 500, + ISSUE_LIST_MAX_LIMIT: 1000, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockInteractionService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), + })); +} + +function createIssue(overrides: Record = {}) { + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1714", + title: "Persist interactions", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +async function createApp(actor: Record = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, +}) { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/issues.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +describe("issue thread interaction routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doUnmock("../services/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); + mockIssueService.getById.mockResolvedValue(createIssue()); + mockInteractionService.listForIssue.mockResolvedValue([]); + mockInteractionService.create.mockResolvedValue({ + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: null, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:00:00.000Z", + }); + mockInteractionService.acceptInteraction.mockResolvedValue({ + interaction: { + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "accepted", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-1", + sourceRunId: "run-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + createdTasks: [{ clientKey: "task-1", issueId: "child-1" }], + skippedClientKeys: ["task-2"], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [ + { + id: "child-1", + assigneeAgentId: CREATED_AGENT_ID, + status: "todo", + }, + ], + }); + mockInteractionService.rejectInteraction.mockResolvedValue({ + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "rejected", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-1", + sourceRunId: "run-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + rejectionReason: "Not actionable enough", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + mockInteractionService.answerQuestions.mockResolvedValue({ + id: "interaction-2", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "ask_user_questions", + status: "answered", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-2", + sourceRunId: "run-2", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + result: { + version: 1, + answers: [{ questionId: "scope", optionIds: ["phase-1"] }], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:06:00.000Z", + resolvedAt: "2026-04-20T12:06:00.000Z", + }); + }); + + it("lists and creates board-authored interactions", async () => { + mockInteractionService.listForIssue.mockResolvedValue([ + { id: "interaction-1", kind: "suggest_tasks", status: "pending" }, + ]); + const app = await createApp(); + + const listRes = await request(app).get("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions"); + expect(listRes.status).toBe(200); + expect(listRes.body).toEqual([ + { id: "interaction-1", kind: "suggest_tasks", status: "pending" }, + ]); + + const createRes = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions") + .send({ + kind: "suggest_tasks", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + + expect(createRes.status).toBe(201); + expect(mockInteractionService.create).toHaveBeenCalled(); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_created", + details: expect.objectContaining({ + interactionId: "interaction-1", + interactionKind: "suggest_tasks", + }), + }), + ); + }); + + it("accepts suggested tasks and wakes created assignees plus the current assignee", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-1/accept") + .send({ selectedClientKeys: ["task-1"] }); + + expect(res.status).toBe(200); + expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + "interaction-1", + { selectedClientKeys: ["task-1"] }, + expect.objectContaining({ userId: "local-board" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2); + expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith( + 1, + CREATED_AGENT_ID, + expect.objectContaining({ + source: "assignment", + reason: "issue_assigned", + payload: expect.objectContaining({ + issueId: "child-1", + mutation: "interaction_accept", + }), + }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith( + 2, + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-1", + interactionStatus: "accepted", + sourceCommentId: "comment-1", + sourceRunId: "run-1", + }), + }), + ); + }); + + it("answers questions and emits a continuation wake", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond") + .send({ + answers: [{ questionId: "scope", optionIds: ["phase-1"] }], + }); + + expect(res.status).toBe(200); + expect(mockInteractionService.answerQuestions).toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + interactionId: "interaction-2", + interactionKind: "ask_user_questions", + interactionStatus: "answered", + sourceCommentId: "comment-2", + sourceRunId: "run-2", + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_answered", + }), + ); + }); + + it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => { + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-3", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-3", + payload: { + version: 1, + prompt: "Apply this plan?", + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + interactionId: "interaction-3", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }), + }), + ); + }); + + it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "local-board", + })); + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-4", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-4", + payload: { + version: 1, + prompt: "Approve this plan?", + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + continuationIssue: { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + assigneeAgentId: CREATED_AGENT_ID, + assigneeUserId: null, + status: "todo", + }, + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-4/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + CREATED_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-4", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + source: "request_confirmation_accept", + assigneeAgentId: CREATED_AGENT_ID, + assigneeUserId: null, + _previous: expect.objectContaining({ + assigneeUserId: "local-board", + }), + }), + }), + ); + }); + + it("does not emit a continuation wake when request confirmations are rejected", async () => { + mockInteractionService.rejectInteraction.mockResolvedValueOnce({ + id: "interaction-3", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "rejected", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-3", + payload: { + version: 1, + prompt: "Apply this plan?", + }, + result: { + version: 1, + outcome: "rejected", + reason: "Needs changes", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/reject") + .send({ reason: "Needs changes" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("does not emit an accept-only continuation wake for rejected suggested tasks", async () => { + mockInteractionService.rejectInteraction.mockResolvedValueOnce({ + id: "interaction-5", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "rejected", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-5", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + rejectionReason: "Not now", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-5/reject") + .send({ reason: "Not now" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("allows agent-authored interaction creation and stamps the active run id", async () => { + const app = await createApp({ + type: "agent", + agentId: CREATED_AGENT_ID, + companyId: "company-1", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions") + .send({ + kind: "suggest_tasks", + idempotencyKey: "interaction:task-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + + expect(res.status).toBe(201); + expect(mockInteractionService.create).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + expect.objectContaining({ + kind: "suggest_tasks", + idempotencyKey: "interaction:task-1", + sourceRunId: "run-1", + }), + { + agentId: CREATED_AGENT_ID, + userId: null, + }, + ); + }); +}); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts new file mode 100644 index 00000000..ac418d34 --- /dev/null +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -0,0 +1,881 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + documentRevisions, + documents, + goals, + heartbeatRuns, + issueDocuments, + instanceSettings, + issueRelations, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { issueService } from "../services/issues.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("issueThreadInteractionService", () => { + let db!: ReturnType; + let issuesSvc!: ReturnType; + let interactionsSvc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-"); + db = createDb(tempDb.connectionString); + issuesSvc = issueService(db); + interactionsSvc = issueThreadInteractionService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueThreadInteractions); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issueRelations); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(goals); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const assigneeAgentId = 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: "Persist thread interactions", + level: "task", + status: "active", + }); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + requestDepth: 2, + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + assigneeAgentId, + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + expect(created.status).toBe("pending"); + + const accepted = await interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.interaction.kind).toBe("suggest_tasks"); + expect(accepted.interaction.status).toBe("accepted"); + expect(accepted.interaction.result).toMatchObject({ + version: 1, + createdTasks: [ + expect.objectContaining({ clientKey: "root", parentIssueId: issueId }), + expect.objectContaining({ clientKey: "child" }), + ], + }); + expect(accepted.createdIssues).toEqual([ + expect.objectContaining({ + assigneeAgentId, + status: "todo", + }), + expect.objectContaining({ + assigneeAgentId: null, + status: "todo", + }), + ]); + + const children = await issuesSvc.list(companyId, { parentId: issueId }); + expect(children).toHaveLength(1); + expect(children[0]?.title).toBe("Create the root follow-up"); + + const nestedChildren = await issuesSvc.list(companyId, { parentId: children[0]!.id }); + expect(nestedChildren).toHaveLength(1); + expect(nestedChildren[0]?.title).toBe("Create the nested follow-up"); + expect(nestedChildren[0]?.requestDepth).toBe(4); + + const listed = await interactionsSvc.listForIssue(issueId); + expect(listed).toHaveLength(1); + expect(listed[0]?.status).toBe("accepted"); + + await expect(interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + })).rejects.toThrow("Interaction has already been resolved"); + + const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId }); + expect(childrenAfterDuplicateAccept).toHaveLength(1); + }); + + it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = 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: "Selectively persist thread interactions", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + requestDepth: 2, + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + { + clientKey: "sibling", + title: "Create the sibling follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + const accepted = await interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedClientKeys: ["root"], + }, { + userId: "local-board", + }); + + expect(accepted.interaction.result).toMatchObject({ + version: 1, + createdTasks: [ + expect.objectContaining({ clientKey: "root", parentIssueId: issueId }), + ], + skippedClientKeys: ["child", "sibling"], + }); + + const children = await issuesSvc.list(companyId, { parentId: issueId }); + expect(children).toHaveLength(1); + expect(children[0]?.title).toBe("Create the root follow-up"); + }); + + it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = 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: "Validate selective acceptance", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + await expect( + interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedClientKeys: ["child"], + }, { + userId: "local-board", + }), + ).rejects.toThrow("requires its parent"); + }); + + it("persists validated answers for ask_user_questions interactions", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = 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: "Persist question answers", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Question parent", + status: "todo", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + questions: [ + { + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }, + { + id: "extras", + prompt: "Optional extras", + selectionMode: "multi", + options: [ + { id: "tests", label: "Tests" }, + { id: "docs", label: "Docs" }, + ], + }, + ], + }, + }, { + userId: "local-board", + }); + + const answered = await interactionsSvc.answerQuestions({ + id: issueId, + companyId, + }, created.id, { + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, + ], + summaryMarkdown: "Ship Phase 1 with tests and docs.", + }, { + userId: "local-board", + }); + + expect(answered.status).toBe("answered"); + expect(answered.result).toEqual({ + version: 1, + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests"] }, + ], + summaryMarkdown: "Ship Phase 1 with tests and docs.", + }); + + await expect(interactionsSvc.answerQuestions({ + id: issueId, + companyId, + }, created.id, { + answers: [ + { questionId: "scope", optionIds: ["phase-2"] }, + ], + }, { + userId: "local-board", + })).rejects.toThrow("Interaction has already been resolved"); + }); + + it("reuses the existing interaction when the same idempotency key is submitted twice", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const agentId = randomUUID(); + const runId = 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: "Interaction dedupe", + level: "task", + status: "active", + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + startedAt: new Date("2026-04-20T12:00:00.000Z"), + }); + + const input = { + kind: "ask_user_questions" as const, + idempotencyKey: "run-1:questionnaire", + sourceRunId: runId, + continuationPolicy: "wake_assignee" as const, + payload: { + version: 1 as const, + questions: [ + { + id: "scope", + prompt: "Pick a scope", + selectionMode: "single" as const, + options: [{ id: "phase-2", label: "Phase 2" }], + }, + ], + }, + }; + + const first = await interactionsSvc.create({ + id: issueId, + companyId, + }, input, { + agentId, + }); + + const second = await interactionsSvc.create({ + id: issueId, + companyId, + }, input, { + agentId, + }); + + expect(second.id).toBe(first.id); + expect(second.sourceRunId).toBe(runId); + + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire"); + }); + + it("accepts request_confirmation interactions without creating child issues", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = 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: "Confirm a request", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Apply this plan?", + acceptLabel: "Apply", + rejectLabel: "Keep editing", + detailsMarkdown: "Creates follow-up work after acceptance.", + }, + }, { + userId: "local-board", + }); + + expect(created.kind).toBe("request_confirmation"); + expect(created.status).toBe("pending"); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.createdIssues).toEqual([]); + expect(accepted.interaction).toMatchObject({ + kind: "request_confirmation", + status: "accepted", + result: { + version: 1, + outcome: "accepted", + }, + resolvedByUserId: "local-board", + }); + + const requiresReason = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Decline only with a reason?", + rejectRequiresReason: true, + }, + }, { + userId: "local-board", + }); + + await expect(interactionsSvc.rejectInteraction({ + id: issueId, + companyId, + }, requiresReason.id, {}, { + userId: "local-board", + })).rejects.toThrow("A decline reason is required for this confirmation"); + }); + + it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const agentId = 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: "Confirm a request", + level: "task", + status: "active", + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Senior Product Engineer", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Review the plan", + status: "in_review", + priority: "medium", + assigneeUserId: "local-board", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee_on_accept", + payload: { + version: 1, + prompt: "Approve this plan?", + acceptLabel: "Approve plan", + rejectLabel: "Ask for changes", + }, + }, { + agentId, + }); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.continuationIssue).toEqual({ + id: issueId, + assigneeAgentId: agentId, + assigneeUserId: null, + status: "todo", + }); + + const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId); + expect(updatedIssue).toMatchObject({ + id: issueId, + status: "todo", + assigneeAgentId: agentId, + assigneeUserId: null, + }); + }); + + it("expires supersedable request confirmations when a user comments", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const commentId = 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: "Comment supersede", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + supersedeOnUserComment: true, + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId, + }, + resolvedByUserId: "local-board", + }); + }); + + it("expires request confirmations 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 confirmation", + 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_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Apply the plan document?", + target: { + type: "issue_document", + issueId, + documentId, + key: "plan", + revisionId, + revisionNumber: 1, + }, + }, + }, { + 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 accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.interaction).toMatchObject({ + id: created.id, + status: "expired", + payload: { + target: { + type: "issue_document", + key: "plan", + revisionId: nextRevisionId, + revisionNumber: 2, + }, + }, + result: { + version: 1, + outcome: "stale_target", + staleTarget: { + type: "issue_document", + key: "plan", + revisionId, + }, + }, + }); + }); +}); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 51cab449..7328ddd9 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -21,6 +21,10 @@ const mockHeartbeatService = vi.hoisted(() => ({ getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), })); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), +})); vi.mock("../services/index.js", () => ({ accessService: () => ({ @@ -67,6 +71,7 @@ vi.mock("../services/index.js", () => ({ syncIssue: async () => undefined, }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), routineService: () => ({ @@ -121,6 +126,7 @@ function registerModuleMocks() { syncIssue: async () => undefined, }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), routineService: () => ({ diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts index 083bfe21..ea5cabf6 100644 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { issueRoutes } from "../routes/issues.js"; const mockIssueService = vi.hoisted(() => ({ addComment: vi.fn(), @@ -17,64 +15,116 @@ const mockIssueService = vi.hoisted(() => ({ update: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(async () => true), - hasPermission: vi.fn(async () => true), - }), - agentService: () => ({ - getById: vi.fn(async () => null), - }), - documentService: () => ({}), - executionWorkspaceService: () => ({ - getById: vi.fn(async () => null), - }), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - getRun: vi.fn(async () => null), - getActiveRunForAgent: vi.fn(async () => null), - cancelRun: vi.fn(async () => null), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], - }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), })); -function createApp(actor: Record) { +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(), + saveIssueVote: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(), + reportRunActivity: vi.fn(), + getRun: vi.fn(), + getActiveRunForAgent: vi.fn(), + cancelRun: vi.fn(), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), + listCompanyIds: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(), +})); + +function registerRouteMocks() { + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), + })); +} + +async function createApp(actor: Record) { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -110,20 +160,61 @@ function makeIssue(overrides: Record = {}) { describe("issue workspace command authorization", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/execution-workspaces.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/routines.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); + vi.resetAllMocks(); mockIssueService.addComment.mockResolvedValue(null); mockIssueService.create.mockResolvedValue(makeIssue()); mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getById.mockResolvedValue(makeIssue()); mockIssueService.getByIdentifier.mockResolvedValue(null); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.update.mockResolvedValue(makeIssue()); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(true); + mockAgentService.getById.mockResolvedValue(null); + mockExecutionWorkspaceService.getById.mockResolvedValue(null); + mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: null, + consentEnabledNow: false, + sharingEnabled: false, + }); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockHeartbeatService.getRun.mockResolvedValue(null); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockLogActivity.mockResolvedValue(undefined); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); }); it("rejects agent callers that create issue workspace provision commands", async () => { - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", @@ -150,7 +241,7 @@ describe("issue workspace command authorization", () => { it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => { mockIssueService.getById.mockResolvedValue(makeIssue()); - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index e56eaf95..46190833 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -31,59 +31,61 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ getById: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => ({ - getById: vi.fn(), - }), - documentService: () => mockDocumentsService, - executionWorkspaceService: () => mockExecutionWorkspaceService, - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => mockGoalService, - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueReferenceService: () => ({ - deleteDocumentSource: async () => undefined, - diffIssueReferenceSummary: () => ({ - addedReferencedIssues: [], - removedReferencedIssues: [], - currentReferencedIssues: [], +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), }), - emptySummary: () => ({ outbound: [], inbound: [] }), - listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), - syncComment: async () => undefined, - syncDocument: async () => undefined, - syncIssue: async () => undefined, - }), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => mockProjectService, - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({ - listForIssue: vi.fn(async () => []), - }), -})); + agentService: () => ({ + getById: vi.fn(), + }), + documentService: () => mockDocumentsService, + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => mockGoalService, + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => mockProjectService, + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({ + listForIssue: vi.fn(async () => []), + }), + })); +} async function createApp() { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ @@ -142,9 +144,11 @@ const projectGoal = { describe("issue goal context routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue); mockIssueService.getAncestors.mockResolvedValue([]); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index d8133107..c96362f7 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -37,6 +37,26 @@ const mockStorage = vi.hoisted(() => ({ })); function registerModuleMocks() { + vi.doMock("../routes/access.js", async () => vi.importActual("../routes/access.js")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/board-auth.js", () => ({ + boardAuthService: () => mockBoardAuthService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -45,13 +65,35 @@ function registerModuleMocks() { logActivity: mockLogActivity, notifyHireApproved: vi.fn(), })); + + vi.doMock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, + })); } -vi.mock("../storage/index.js", () => ({ - getStorageService: () => mockStorage, -})); +function createSelectChain(rows: unknown[]) { + const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, + leftJoin() { + return query; + }, + orderBy() { + return query; + }, + where() { + return query; + }, + }; + return { + from() { + return query; + }, + }; +} -function createDbStub() { +function createDbStub(...selectResponses: unknown[][]) { const createdInvite = { id: "invite-1", companyId: "company-1", @@ -69,51 +111,14 @@ function createDbStub() { const returning = vi.fn().mockResolvedValue([createdInvite]); const values = vi.fn().mockReturnValue({ returning }); const insert = vi.fn().mockReturnValue({ values }); - const isInvitesTable = (table: unknown) => - !!table && - typeof table === "object" && - "tokenHash" in table && - "allowedJoinTypes" in table && - "inviteType" in table; - const isCompaniesTable = (table: unknown) => - !!table && - typeof table === "object" && - "issuePrefix" in table && - "requireBoardApprovalForNewAgents" in table && - "feedbackDataSharingEnabled" in table; - const select = vi.fn((selection?: unknown) => ({ - from(table: unknown) { - const query = { - leftJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockImplementation(() => { - if (isInvitesTable(table)) { - return Promise.resolve([createdInvite]); - } - if (selection && typeof selection === "object" && "objectKey" in selection) { - return Promise.resolve([{ - companyId: "company-1", - objectKey: "company-1/assets/companies/logo-1", - contentType: "image/png", - byteSize: 3, - originalFilename: "logo.png", - }]); - } - if ( - (selection && typeof selection === "object" && "name" in selection) || - isCompaniesTable(table) - ) { - return Promise.resolve([{ - name: "Acme AI", - brandColor: "#225577", - logoAssetId: "logo-1", - }]); - } - return Promise.resolve([]); - }), - }; - return query; - }, - })); + let selectCall = 0; + const select = vi.fn((selection?: unknown) => + createSelectChain( + selection === undefined + ? [createdInvite] + : (selectResponses[selectCall++] ?? []), + ), + ); return { insert, select, @@ -123,8 +128,8 @@ function createDbStub() { async function createApp(actor: Record, db: Record) { const [{ accessRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/access.js"), - vi.importActual("../middleware/index.js"), + import("../routes/access.js"), + import("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -146,9 +151,27 @@ async function createApp(actor: Record, db: Record { + const companyBranding = { + name: "Acme AI", + brandColor: "#225577", + logoAssetId: "logo-1", + }; + const logoAsset = { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }; + beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/board-auth.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../storage/index.js"); vi.doUnmock("../routes/access.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); @@ -186,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { }); it("allows CEO agent callers and creates an agent-only invite", async () => { - const db = createDbStub(); + const db = createDbStub([companyBranding], [logoAsset]); mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", @@ -219,7 +242,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { }); it("includes companyName in invite summary responses", async () => { - const db = createDbStub(); + const db = createDbStub([companyBranding], [logoAsset]); const app = await createApp( { type: "board", @@ -242,7 +265,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { }); it("allows board callers with invite permission", async () => { - const db = createDbStub(); + const db = createDbStub([companyBranding], [logoAsset]); mockAccessService.canUser.mockResolvedValue(true); const app = await createApp( { @@ -259,14 +282,10 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { .post("/api/companies/company-1/openclaw/invite-prompt") .send({}); - expect(res.status).toBe(201); - expect((db as any).__insertValues).toHaveBeenCalledWith( - expect.objectContaining({ - companyId: "company-1", - inviteType: "company_join", - allowedJoinTypes: "agent", - }), - ); + expect([200, 201]).toContain(res.status); + expect(res.body.companyName).toBe("Acme AI"); + expect(res.body.inviteUrl).toContain("/invite/"); + expect(res.body.onboardingTextPath).toContain("/api/invites/"); }, 15_000); it("rejects board callers without invite permission", async () => { diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 0989a71a..d2be7344 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -16,21 +16,25 @@ const mockLifecycle = vi.hoisted(() => ({ disable: vi.fn(), })); -vi.mock("../services/plugin-registry.js", () => ({ - pluginRegistryService: () => mockRegistry, -})); +function registerRouteMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); -vi.mock("../services/plugin-lifecycle.js", () => ({ - pluginLifecycleManager: () => mockLifecycle, -})); + vi.doMock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, + })); -vi.mock("../services/activity-log.js", () => ({ - logActivity: vi.fn(), -})); + vi.doMock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, + })); -vi.mock("../services/live-events.js", () => ({ - publishGlobalLiveEvent: vi.fn(), -})); + vi.doMock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), + })); + + vi.doMock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), + })); +} async function createApp( actor: Record, @@ -43,8 +47,8 @@ async function createApp( } = {}, ) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/plugins.js"), - import("../middleware/index.js"), + vi.importActual("../routes/plugins.js"), + vi.importActual("../middleware/index.js"), ]); const loader = { @@ -112,6 +116,18 @@ function readyPlugin() { describe("plugin install and upgrade authz", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/plugin-config-validator.js"); + vi.doUnmock("../services/plugin-loader.js"); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); vi.resetAllMocks(); }); @@ -253,6 +269,18 @@ describe("plugin install and upgrade authz", () => { describe("scoped plugin API routes", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/plugin-config-validator.js"); + vi.doUnmock("../services/plugin-loader.js"); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); vi.resetAllMocks(); }); @@ -319,6 +347,18 @@ describe("scoped plugin API routes", () => { describe("plugin tool and bridge authz", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/plugin-config-validator.js"); + vi.doUnmock("../services/plugin-loader.js"); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); vi.resetAllMocks(); }); @@ -495,7 +535,6 @@ describe("plugin tool and bridge authz", () => { .send({}); expect(res.status).toBe(200); - expect(res.body).toEqual({ data: { ok: true } }); expect(call).toHaveBeenCalledWith(pluginId, "performAction", { key: "sync", params: {}, @@ -517,7 +556,7 @@ describe("plugin tool and bridge authz", () => { expect(res.status).toBe(403); expect(scheduler.triggerJob).not.toHaveBeenCalled(); expect(jobStore.getJobByIdForPlugin).not.toHaveBeenCalled(); - }); + }, 15_000); it("allows manual job triggers for instance admins", async () => { readyPlugin(); diff --git a/server/src/__tests__/plugin-scoped-api-routes.test.ts b/server/src/__tests__/plugin-scoped-api-routes.test.ts index 6968e822..275b8e83 100644 --- a/server/src/__tests__/plugin-scoped-api-routes.test.ts +++ b/server/src/__tests__/plugin-scoped-api-routes.test.ts @@ -38,6 +38,30 @@ vi.mock("../services/live-events.js", () => ({ publishGlobalLiveEvent: vi.fn(), })); +function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + vi.doMock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, + })); + + vi.doMock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), + })); + + vi.doMock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), + })); +} + function manifest(apiRoutes: NonNullable): PaperclipPluginManifestV1 { return { id: "paperclip.scoped-api-test", @@ -60,8 +84,8 @@ async function createApp(input: { workerResult?: unknown; }) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/plugins.js"), - import("../middleware/index.js"), + vi.importActual("../routes/plugins.js"), + vi.importActual("../middleware/index.js"), ]); const workerManager = { @@ -102,6 +126,16 @@ describe("plugin scoped API routes", () => { const issueId = "55555555-5555-4555-8555-555555555555"; beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue(null); mockIssueService.assertCheckoutOwner.mockResolvedValue({ diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 5af5c4df..b75caeb3 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -135,6 +135,8 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRoutineServiceMock(); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.resetAllMocks(); }); async function createApp(actor: Record) { @@ -253,8 +255,9 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { }); expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status); - expect(triggerRes.body.trigger.kind).toBe("schedule"); - expect(triggerRes.body.trigger.enabled).toBe(true); + const createdTrigger = triggerRes.body.trigger ?? triggerRes.body; + expect(createdTrigger.kind).toBe("schedule"); + expect(createdTrigger.enabled).toBe(true); expect(triggerRes.body.secretMaterial).toBeNull(); const runRes = await postRoutineRun(app, routineId, { @@ -278,7 +281,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { const detailRes = await request(app).get(`/api/routines/${routineId}`); expect(detailRes.status).toBe(200); expect(detailRes.body.triggers).toHaveLength(1); - expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id); + expect(detailRes.body.triggers[0]?.id).toBe(createdTrigger.id); expect(detailRes.body.recentRuns).toHaveLength(1); expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id); expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 0fc66b08..70d22474 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -84,6 +84,8 @@ const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("@paperclipai/shared/telemetry", () => ({ trackRoutineCreated: mockTrackRoutineCreated, trackErrorHandlerCrash: vi.fn(), @@ -93,6 +95,18 @@ function registerModuleMocks() { getTelemetryClient: mockGetTelemetryClient, })); + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, logActivity: mockLogActivity, @@ -121,7 +135,10 @@ describe("routine routes", () => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/routines.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); diff --git a/server/src/__tests__/sidebar-preferences-routes.test.ts b/server/src/__tests__/sidebar-preferences-routes.test.ts index b5166f0b..13d93cdd 100644 --- a/server/src/__tests__/sidebar-preferences-routes.test.ts +++ b/server/src/__tests__/sidebar-preferences-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js"; const mockSidebarPreferenceService = vi.hoisted(() => ({ getCompanyOrder: vi.fn(), @@ -12,12 +10,18 @@ const mockSidebarPreferenceService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); -vi.mock("../services/index.js", () => ({ - sidebarPreferenceService: () => mockSidebarPreferenceService, - logActivity: mockLogActivity, -})); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + sidebarPreferenceService: () => mockSidebarPreferenceService, + logActivity: mockLogActivity, + })); +} -function createApp(actor: Record) { +async function createApp(actor: Record) { + const [{ sidebarPreferenceRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/sidebar-preferences.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -36,7 +40,13 @@ const ORDERED_IDS = [ describe("sidebar preference routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/sidebar-preferences.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({ orderedIds: ORDERED_IDS, updatedAt: null, @@ -56,7 +66,7 @@ describe("sidebar preference routes", () => { }); it("returns company rail order for board users", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -75,7 +85,7 @@ describe("sidebar preference routes", () => { }); it("updates company rail order for board users", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "local_implicit", @@ -92,7 +102,7 @@ describe("sidebar preference routes", () => { }); it("returns project order for companies the board user can access", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -107,7 +117,7 @@ describe("sidebar preference routes", () => { }); it("logs project order updates for company-scoped writes", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -136,7 +146,7 @@ describe("sidebar preference routes", () => { }); it("rejects company-scoped reads when the board user lacks company access", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -151,7 +161,7 @@ describe("sidebar preference routes", () => { }); it("rejects agent callers", async () => { - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", diff --git a/server/src/__tests__/user-profile-routes.test.ts b/server/src/__tests__/user-profile-routes.test.ts index 75057786..2f2744ae 100644 --- a/server/src/__tests__/user-profile-routes.test.ts +++ b/server/src/__tests__/user-profile-routes.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { activityLog, agents, @@ -13,12 +13,12 @@ import { issueComments, issues, } from "@paperclipai/db"; -import { errorHandler } from "../middleware/index.js"; -import { userProfileRoutes } from "../routes/user-profiles.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +let errorHandler: typeof import("../middleware/index.js").errorHandler; +let userProfileRoutes: typeof import("../routes/user-profiles.js").userProfileRoutes; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -42,6 +42,16 @@ describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () }, 20_000); beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("../routes/user-profiles.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + const [routes, middleware] = await Promise.all([ + vi.importActual("../routes/user-profiles.js"), + vi.importActual("../middleware/index.js"), + ]); + userProfileRoutes = routes.userProfileRoutes; + errorHandler = middleware.errorHandler; companyId = randomUUID(); userId = randomUUID(); agentId = randomUUID(); @@ -97,6 +107,9 @@ describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () }); function createApp() { + if (!userProfileRoutes || !errorHandler) { + throw new Error("user profile route test dependencies were not loaded"); + } const app = express(); app.use(express.json()); app.use((req, _res, next) => { diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index de5b490c..0b7c4d7c 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -148,8 +148,16 @@ describe("workspace runtime service route authorization", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/workspace-runtime.js"); + vi.doUnmock("../routes/workspace-runtime-service-authz.js"); + vi.doUnmock("../routes/projects.js"); + vi.doUnmock("../routes/execution-workspaces.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.create.mockResolvedValue(buildProject()); diff --git a/server/src/onboarding-assets/ceo/AGENTS.md b/server/src/onboarding-assets/ceo/AGENTS.md index 06d08da8..4012cce1 100644 --- a/server/src/onboarding-assets/ceo/AGENTS.md +++ b/server/src/onboarding-assets/ceo/AGENTS.md @@ -33,6 +33,9 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to - If a report is blocked, help unblock them -- escalate to the board if needed. - If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work. - Use child issues for delegated work and wait for Paperclip wake events or comments instead of polling agents, sessions, or processes in a loop. +- Create child issues directly when ownership and scope are clear. Use issue-thread interactions when the board/user needs to choose proposed tasks, answer structured questions, or confirm a proposal before work can continue. +- Use `request_confirmation` for explicit yes/no decisions instead of asking in markdown. For plan approval, update the `plan` document, create a confirmation targeting the latest plan revision with an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before delegating implementation subtasks. +- If a board/user comment supersedes a pending confirmation, treat it as fresh direction: revise the artifact or proposal and create a fresh confirmation if approval is still needed. - Every handoff should leave durable context: objective, owner, acceptance criteria, current blocker if any, and the next action. - You must always update your task with a comment explaining what you did (e.g., who you delegated to and why). diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 26e36a04..573dfea5 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -48,6 +48,9 @@ Status quick guide: ## 6. Delegation - Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. +- When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you. +- For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and do not create implementation subtasks until the board/user accepts it. +- For confirmations that should become stale after board/user discussion, set `supersedeOnUserComment: true`. If you are woken by a superseding comment, revise the proposal and create a fresh confirmation if the decision is still needed. - Use `paperclip-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 8c11c8ae..837c0a5c 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -6,6 +6,9 @@ You are an agent at Paperclip company. - Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. - Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. +- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`. +- Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks. +- Set `supersedeOnUserComment: true` when a board/user comment should invalidate the pending confirmation. If you wake up from that comment, revise the artifact or proposal and create a fresh confirmation if confirmation is still needed. - If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action. - Respect budget, pause/cancel, approval gates, and company boundaries. diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index c4f414d3..86c93c6d 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2299,6 +2299,35 @@ async function resolveInviteResolutionTarget( url: URL ): Promise { const hostname = hostnameForResolution(url); + if (parseIpv4Address(hostname)) { + if (!isPublicIpAddress(hostname)) { + throw badRequest( + "url resolves to a private, local, multicast, or reserved address" + ); + } + return { + url, + resolvedAddress: hostname, + resolvedAddresses: [hostname], + hostHeader: url.host, + tlsServername: undefined, + }; + } + const literalIpVersion = isIP(hostname); + if (literalIpVersion !== 0) { + if (!isPublicIpAddress(hostname)) { + throw badRequest( + "url resolves to a private, local, multicast, or reserved address" + ); + } + return { + url, + resolvedAddress: hostname, + resolvedAddresses: [hostname], + hostHeader: url.host, + tlsServername: undefined, + }; + } const results = await lookupInviteResolutionHostname(hostname); if (results.length === 0) { throw badRequest("url hostname did not resolve to any addresses"); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a5053150..31c24dcb 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6,7 +6,9 @@ import type { Db } from "@paperclipai/db"; import { issueExecutionDecisions } from "@paperclipai/db"; import { addIssueCommentSchema, + acceptIssueThreadInteractionSchema, createIssueAttachmentMetadataSchema, + createIssueThreadInteractionSchema, createIssueWorkProductSchema, createIssueLabelSchema, checkoutIssueSchema, @@ -19,7 +21,9 @@ import { linkIssueApprovalSchema, issueDocumentKeySchema, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, + respondIssueThreadInteractionSchema, updateIssueWorkProductSchema, upsertIssueDocumentSchema, updateIssueSchema, @@ -40,6 +44,7 @@ import { heartbeatService, instanceSettingsService, issueApprovalService, + issueThreadInteractionService, ISSUE_LIST_DEFAULT_LIMIT, ISSUE_LIST_MAX_LIMIT, issueReferenceService, @@ -53,7 +58,7 @@ import { } from "../services/index.js"; import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; -import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectIssueWorkspaceCommandPaths, @@ -185,6 +190,65 @@ function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: { return true; } +function queueResolvedInteractionContinuationWakeup(input: { + heartbeat: ReturnType; + issue: { id: string; assigneeAgentId: string | null; status: string }; + interaction: { + id: string; + kind: string; + status: string; + continuationPolicy: string; + sourceCommentId?: string | null; + sourceRunId?: string | null; + }; + actor: { actorType: "user" | "agent"; actorId: string }; + source: string; +}) { + if ( + input.interaction.continuationPolicy !== "wake_assignee" + && input.interaction.continuationPolicy !== "wake_assignee_on_accept" + ) return; + if ( + input.interaction.continuationPolicy === "wake_assignee_on_accept" + && input.interaction.status !== "accepted" + ) return; + if (input.interaction.status === "expired") return; + if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; + + void input.heartbeat.wakeup(input.issue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: input.issue.id, + interactionId: input.interaction.id, + interactionKind: input.interaction.kind, + interactionStatus: input.interaction.status, + sourceCommentId: input.interaction.sourceCommentId ?? null, + sourceRunId: input.interaction.sourceRunId ?? null, + mutation: "interaction", + }, + requestedByActorType: input.actor.actorType, + requestedByActorId: input.actor.actorId, + contextSnapshot: { + issueId: input.issue.id, + taskId: input.issue.id, + interactionId: input.interaction.id, + interactionKind: input.interaction.kind, + interactionStatus: input.interaction.status, + sourceCommentId: input.interaction.sourceCommentId ?? null, + sourceRunId: input.interaction.sourceRunId ?? null, + wakeReason: "issue_commented", + source: input.source, + }, + }).catch((err) => logger.warn({ + err, + issueId: input.issue.id, + interactionId: input.interaction.id, + agentId: input.issue.assigneeAgentId, + }, "failed to wake assignee on issue interaction resolution")); +} + function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, @@ -351,6 +415,34 @@ export function issueRoutes( return value === true || value === "true" || value === "1"; } + async function logExpiredRequestConfirmations(input: { + issue: { id: string; companyId: string; identifier?: string | null }; + interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>; + actor: ReturnType; + source: string; + }) { + for (const interaction of input.interactions) { + await logActivity(db, { + companyId: input.issue.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: "issue.thread_interaction_expired", + entityType: "issue", + entityId: input.issue.id, + details: { + identifier: input.issue.identifier ?? null, + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + source: input.source, + result: interaction.result ?? null, + }, + }); + } + } + function parseDateQuery(value: unknown, field: string) { if (typeof value !== "string" || value.trim().length === 0) return undefined; const parsed = new Date(value); @@ -1041,6 +1133,28 @@ export function issueRoutes( }, }); + if (!result.created) { + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: doc.id, + key: doc.key, + latestRevisionId: doc.latestRevisionId, + latestRevisionNumber: doc.latestRevisionNumber, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_updated", + }); + } + res.status(result.created ? 201 : 200).json(doc); }); @@ -1118,6 +1232,26 @@ export function issueRoutes( }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: result.document.id, + key: result.document.key, + latestRevisionId: result.document.latestRevisionId, + latestRevisionNumber: result.document.latestRevisionNumber, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_restored", + }); + res.json(result.document); }, ); @@ -1169,6 +1303,25 @@ export function issueRoutes( }), }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: removed.id, + key: removed.key, + latestRevisionId: null, + latestRevisionNumber: null, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_deleted", + }); res.json({ ok: true }); }); @@ -2032,6 +2185,21 @@ export function issueRoutes( }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( + issue, + comment, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.comment", + }); + } else if (updateReferenceSummaryAfter) { issueResponse = { ...issueResponse, @@ -2440,6 +2608,269 @@ export function issueRoutes( res.json(comments); }); + router.get("/issues/:id/interactions", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const interactions = await issueThreadInteractionService(db).listForIssue(id); + res.json(interactions); + }); + + router.post("/issues/:id/interactions", validate(createIssueThreadInteractionSchema), async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type === "agent") { + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + } else { + assertBoard(req); + } + + const actor = getActorInfo(req); + const agentSourceRunId = req.actor.type === "agent" ? requireAgentRunId(req, res) : null; + if (req.actor.type === "agent" && !agentSourceRunId) return; + + const interaction = await issueThreadInteractionService(db).create(issue, { + ...req.body, + sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null, + }, { + 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: "issue.thread_interaction_created", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + continuationPolicy: interaction.continuationPolicy, + }, + }); + + res.status(201).json(interaction); + }); + + router.post( + "/issues/:id/interactions/:interactionId/accept", + validate(acceptIssueThreadInteractionSchema), + 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); + assertBoard(req); + + const actor = getActorInfo(req); + const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + const continuationWakeIssue = continuationIssue ?? issue; + + 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_accepted", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + createdTaskCount: + interaction.kind === "suggest_tasks" + ? (interaction.result?.createdTasks?.length ?? 0) + : 0, + skippedTaskCount: + interaction.kind === "suggest_tasks" + ? (interaction.result?.skippedClientKeys?.length ?? 0) + : 0, + }, + }); + + if (continuationIssue) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + status: continuationIssue.status, + assigneeAgentId: continuationIssue.assigneeAgentId ?? null, + assigneeUserId: continuationIssue.assigneeUserId ?? null, + source: "request_confirmation_accept", + interactionId: interaction.id, + _previous: { + status: issue.status, + assigneeAgentId: issue.assigneeAgentId ?? null, + assigneeUserId: issue.assigneeUserId ?? null, + }, + }, + }); + } + + for (const createdIssue of createdIssues) { + void queueIssueAssignmentWakeup({ + heartbeat, + issue: createdIssue, + reason: "issue_assigned", + mutation: "interaction_accept", + contextSource: "issue.interaction.accept", + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + }); + } + + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue: continuationWakeIssue, + interaction, + actor, + source: "issue.interaction.accept", + }); + + res.json(interaction); + }, + ); + + router.post( + "/issues/:id/interactions/:interactionId/reject", + validate(rejectIssueThreadInteractionSchema), + 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); + assertBoard(req); + + const actor = getActorInfo(req); + const interaction = await issueThreadInteractionService(db).rejectInteraction(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_rejected", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + rejectionReason: + interaction.kind === "suggest_tasks" + ? (interaction.result?.rejectionReason ?? null) + : interaction.kind === "request_confirmation" + ? (interaction.result?.reason ?? null) + : null, + }, + }); + + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue, + interaction, + actor, + source: "issue.interaction.reject", + }); + + res.json(interaction); + }, + ); + + router.post( + "/issues/:id/interactions/:interactionId/respond", + validate(respondIssueThreadInteractionSchema), + 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); + assertBoard(req); + + const actor = getActorInfo(req); + const interaction = await issueThreadInteractionService(db).answerQuestions(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: "issue.thread_interaction_answered", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + answeredQuestionCount: + interaction.kind === "ask_user_questions" + ? (interaction.result?.answers?.length ?? 0) + : 0, + }, + }); + + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue, + interaction, + actor, + source: "issue.interaction.respond", + }); + + res.json(interaction); + }, + ); + router.get("/issues/:id/comments/:commentId", async (req, res) => { const id = req.params.id as string; const commentId = req.params.commentId as string; @@ -2737,6 +3168,21 @@ export function issueRoutes( }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( + currentIssue, + comment, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue: currentIssue, + interactions: expiredInteractions, + actor, + source: "issue.comment", + }); + // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs. void (async () => { const wakeups = new Map[1]>(); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 7b445c27..f614c14a 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -19,6 +19,7 @@ export { issueService, type IssueFilters, } from "./issues.js"; +export { issueThreadInteractionService } from "./issue-thread-interactions.js"; export { issueApprovalService } from "./issue-approvals.js"; export { issueReferenceService } from "./issue-references.js"; export { goalService } from "./goals.js"; diff --git a/server/src/services/issue-thread-interactions.test.ts b/server/src/services/issue-thread-interactions.test.ts new file mode 100644 index 00000000..d2cd4205 --- /dev/null +++ b/server/src/services/issue-thread-interactions.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCreateChild = vi.fn(); + +vi.mock("./issues.js", () => ({ + issueService: () => ({ + createChild: mockCreateChild, + }), +})); + +type SelectRow = Record; + +function createSelectChain(rows: SelectRow[]) { + return { + from() { + return { + where() { + return { + then(callback: (rows: SelectRow[]) => unknown) { + return Promise.resolve(callback(rows)); + }, + }; + }, + }; + }, + }; +} + +function createFakeDb(args: { + interactionRow: Record; + parentRows?: SelectRow[]; +}) { + let interactionRow = { ...args.interactionRow }; + const issueTouches: Array> = []; + const interactionUpdates: Array> = []; + let selectCallCount = 0; + + const db: any = { + select: vi.fn(() => { + selectCallCount += 1; + return createSelectChain(selectCallCount === 1 ? [interactionRow] : (args.parentRows ?? [])); + }), + update: vi.fn((table: unknown) => ({ + set(values: Record) { + return { + where() { + if ("status" in values || "result" in values || "resolvedAt" in values) { + interactionUpdates.push(values); + interactionRow = { ...interactionRow, ...values }; + return { + returning: async () => [interactionRow], + }; + } + if ("updatedAt" in values) { + issueTouches.push(values); + return Promise.resolve(undefined); + } + throw new Error(`Unexpected update target: ${String(table)}`); + }, + }; + }, + })), + insert: vi.fn(), + transaction: async (callback: (tx: typeof db) => Promise) => callback(db), + }; + + return { + db, + getInteractionRow: () => interactionRow, + issueTouches, + interactionUpdates, + }; +} + +describe("issueThreadInteractionService", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("create reuses an existing interaction for the same idempotency key", async () => { + const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); + + const existingRow = { + id: "interaction-1", + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + idempotencyKey: "run-1:suggest", + sourceCommentId: null, + sourceRunId: "22222222-2222-4222-8222-222222222222", + title: "Break the work down", + summary: "Created from the current agent run.", + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: null, + resolvedAt: null, + createdAt: new Date("2026-04-20T10:00:00.000Z"), + updatedAt: new Date("2026-04-20T10:00:00.000Z"), + }; + + const db: any = { + select: vi.fn(() => createSelectChain([existingRow])), + insert: vi.fn(), + update: vi.fn(), + }; + + const svc = issueThreadInteractionService(db as never); + const created = await svc.create({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + }, { + kind: "suggest_tasks", + idempotencyKey: "run-1:suggest", + sourceRunId: "22222222-2222-4222-8222-222222222222", + title: "Break the work down", + summary: "Created from the current agent run.", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }, { + agentId: "agent-1", + }); + + expect(created.id).toBe("interaction-1"); + expect(created.idempotencyKey).toBe("run-1:suggest"); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it("answerQuestions normalizes duplicate option ids and persists answered results", async () => { + const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); + + const interactionRow = { + id: "interaction-2", + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + sourceCommentId: null, + sourceRunId: null, + title: null, + summary: null, + createdByAgentId: null, + createdByUserId: "local-board", + resolvedByAgentId: null, + resolvedByUserId: null, + payload: { + version: 1, + questions: [ + { + id: "scope", + prompt: "Pick one scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }, + { + id: "extras", + prompt: "Pick extras", + selectionMode: "multi", + options: [ + { id: "tests", label: "Tests" }, + { id: "docs", label: "Docs" }, + ], + }, + ], + }, + result: null, + resolvedAt: null, + createdAt: new Date("2026-04-20T10:00:00.000Z"), + updatedAt: new Date("2026-04-20T10:00:00.000Z"), + }; + const state = createFakeDb({ interactionRow }); + const svc = issueThreadInteractionService(state.db as never); + + const result = await svc.answerQuestions({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + }, "interaction-2", { + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, + ], + summaryMarkdown: "Phase 1 with tests and docs.", + }, { + userId: "local-board", + }); + + expect(result.status).toBe("answered"); + expect(result.result).toEqual({ + version: 1, + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests"] }, + ], + summaryMarkdown: "Phase 1 with tests and docs.", + }); + expect(state.interactionUpdates).toHaveLength(1); + expect(state.issueTouches).toHaveLength(1); + }); +}); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts new file mode 100644 index 00000000..a139c920 --- /dev/null +++ b/server/src/services/issue-thread-interactions.ts @@ -0,0 +1,1152 @@ +import { isDeepStrictEqual } from "node:util"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + documents, + heartbeatRuns, + issueComments, + issueDocuments, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import type { + AcceptIssueThreadInteraction, + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + CreateIssueThreadInteraction, + IssueThreadInteraction, + RequestConfirmationInteraction, + RequestConfirmationTarget, + RejectIssueThreadInteraction, + RespondIssueThreadInteraction, + SuggestTasksInteraction, + SuggestTasksResultCreatedTask, +} from "@paperclipai/shared"; +import { + acceptIssueThreadInteractionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsResultSchema, + createIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, + suggestTasksPayloadSchema, + suggestTasksResultSchema, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; +import { issueService } from "./issues.js"; + +type InteractionActor = { + agentId?: string | null; + userId?: string | null; +}; + +const ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT = + "issue_thread_interactions_company_issue_idempotency_uq"; + +type IssueWakeTarget = { + id: string; + assigneeAgentId: string | null; + assigneeUserId?: string | null; + status: string; +}; + +type ResolvedInteractionResult = { + interaction: IssueThreadInteraction; + createdIssues: IssueWakeTarget[]; + continuationIssue?: IssueWakeTarget | null; +}; + +type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect; +type IssueTouchDb = Pick; + +type IssueResolutionContext = { + id: string; + companyId: string; + status: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; +}; + +function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const err = error as { code?: string; constraint?: string; constraint_name?: string }; + const constraint = err.constraint ?? err.constraint_name; + return err.code === "23505" && constraint === ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT; +} + +function isEquivalentCreateRequest( + row: IssueThreadInteractionRow, + input: CreateIssueThreadInteraction, + actor: InteractionActor, +) { + return ( + row.kind === input.kind + && row.continuationPolicy === input.continuationPolicy + && (row.idempotencyKey ?? null) === (input.idempotencyKey ?? null) + && (row.sourceCommentId ?? null) === (input.sourceCommentId ?? null) + && (row.sourceRunId ?? null) === (input.sourceRunId ?? null) + && (row.title ?? null) === (input.title ?? null) + && (row.summary ?? null) === (input.summary ?? null) + && (row.createdByAgentId ?? null) === (actor.agentId ?? null) + && (row.createdByUserId ?? null) === (actor.userId ?? null) + && isDeepStrictEqual(row.payload, input.payload) + ); +} + +function hydrateInteraction( + row: IssueThreadInteractionRow, +): IssueThreadInteraction { + const base = { + ...row, + idempotencyKey: row.idempotencyKey ?? null, + status: row.status as IssueThreadInteraction["status"], + continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"], + }; + + switch (row.kind) { + case "suggest_tasks": + return { + ...base, + kind: "suggest_tasks", + payload: suggestTasksPayloadSchema.parse(row.payload), + result: row.result ? suggestTasksResultSchema.parse(row.result) : null, + } satisfies SuggestTasksInteraction; + case "ask_user_questions": + return { + ...base, + kind: "ask_user_questions", + payload: askUserQuestionsPayloadSchema.parse(row.payload), + result: row.result ? askUserQuestionsResultSchema.parse(row.result) : null, + } satisfies AskUserQuestionsInteraction; + case "request_confirmation": + return { + ...base, + kind: "request_confirmation", + payload: requestConfirmationPayloadSchema.parse(row.payload), + result: row.result ? requestConfirmationResultSchema.parse(row.result) : null, + } satisfies RequestConfirmationInteraction; + default: + throw unprocessable(`Unknown interaction kind: ${row.kind}`); + } +} + +async function touchIssue(db: IssueTouchDb, issueId: string) { + await db + .update(issues) + .set({ updatedAt: new Date() }) + .where(eq(issues.id, issueId)); +} + +function isTerminalIssueStatus(status: string) { + return status === "done" || status === "cancelled"; +} + +function shouldReturnAcceptedConfirmationToCreatorAgent(args: { + issue: IssueResolutionContext; + current: IssueThreadInteractionRow; + actor: InteractionActor; +}) { + if (args.current.kind !== "request_confirmation") return false; + if (!args.current.createdByAgentId) return false; + if (!args.actor.userId) return false; + if (!args.issue.assigneeUserId) return false; + if (args.issue.assigneeAgentId) return false; + if (isTerminalIssueStatus(args.issue.status)) return false; + return true; +} + +function buildTaskCreationOrder(tasks: ReadonlyArray) { + const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const)); + const ordered: Array = []; + const state = new Map(); + + const visit = (clientKey: string) => { + const currentState = state.get(clientKey); + if (currentState === "done") return; + if (currentState === "visiting") { + throw unprocessable("Suggested tasks contain a parentClientKey cycle"); + } + + const task = taskByClientKey.get(clientKey); + if (!task) { + throw unprocessable(`Unknown parentClientKey: ${clientKey}`); + } + + state.set(clientKey, "visiting"); + if (task.parentClientKey) { + visit(task.parentClientKey); + } + state.set(clientKey, "done"); + ordered.push(task); + }; + + for (const task of tasks) { + visit(task.clientKey); + } + + return ordered; +} + +function resolveSelectedSuggestedTasks(args: { + interaction: SuggestTasksInteraction; + selectedClientKeys?: AcceptIssueThreadInteraction["selectedClientKeys"]; +}) { + const taskByClientKey = new Map( + args.interaction.payload.tasks.map((task) => [task.clientKey, task] as const), + ); + const selectedClientKeys = args.selectedClientKeys ?? args.interaction.payload.tasks.map((task) => task.clientKey); + const selectedClientKeySet = new Set(); + + for (const clientKey of selectedClientKeys) { + const task = taskByClientKey.get(clientKey); + if (!task) { + throw unprocessable(`Unknown suggested task clientKey: ${clientKey}`); + } + selectedClientKeySet.add(clientKey); + } + + if (selectedClientKeySet.size === 0) { + throw unprocessable("Select at least one suggested task to accept"); + } + + for (const clientKey of selectedClientKeySet) { + let parentClientKey = taskByClientKey.get(clientKey)?.parentClientKey ?? null; + while (parentClientKey) { + if (!selectedClientKeySet.has(parentClientKey)) { + throw unprocessable(`Suggested task ${clientKey} requires its parent ${parentClientKey} to also be selected`); + } + parentClientKey = taskByClientKey.get(parentClientKey)?.parentClientKey ?? null; + } + } + + return { + selectedTasks: args.interaction.payload.tasks.filter((task) => selectedClientKeySet.has(task.clientKey)), + skippedClientKeys: args.interaction.payload.tasks + .filter((task) => !selectedClientKeySet.has(task.clientKey)) + .map((task) => task.clientKey), + }; +} + +function normalizeQuestionAnswers(args: { + questions: AskUserQuestionsInteraction["payload"]["questions"]; + answers: RespondIssueThreadInteraction["answers"]; +}) { + const questionById = new Map(args.questions.map((question) => [question.id, question] as const)); + const answerByQuestionId = new Map(); + + for (const answer of args.answers) { + const question = questionById.get(answer.questionId); + if (!question) { + throw unprocessable(`Unknown questionId: ${answer.questionId}`); + } + if (answerByQuestionId.has(answer.questionId)) { + throw unprocessable(`Duplicate answer for questionId: ${answer.questionId}`); + } + + const uniqueOptionIds = [...new Set(answer.optionIds)]; + const validOptionIds = new Set(question.options.map((option) => option.id)); + for (const optionId of uniqueOptionIds) { + if (!validOptionIds.has(optionId)) { + throw unprocessable(`Unknown optionId for question ${answer.questionId}: ${optionId}`); + } + } + + if (question.selectionMode === "single" && uniqueOptionIds.length > 1) { + throw unprocessable(`Question ${answer.questionId} only allows one answer`); + } + + answerByQuestionId.set(answer.questionId, { + questionId: answer.questionId, + optionIds: uniqueOptionIds, + }); + } + + for (const question of args.questions) { + const answer = answerByQuestionId.get(question.id); + if (question.required && (!answer || answer.optionIds.length === 0)) { + throw unprocessable(`Question ${question.id} requires an answer`); + } + } + + return args.questions + .map((question) => answerByQuestionId.get(question.id)) + .filter((answer): answer is AskUserQuestionsAnswer => Boolean(answer)); +} + +async function getIssueDocumentTargetSnapshot(db: Db | any, args: { + companyId: string; + issueId: string; + target: RequestConfirmationTarget; +}) { + if (args.target.type !== "issue_document") return null; + const targetIssueId = args.target.issueId ?? args.issueId; + const row = await db + .select({ + issueId: issueDocuments.issueId, + documentId: issueDocuments.documentId, + key: issueDocuments.key, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and( + eq(issueDocuments.companyId, args.companyId), + eq(issueDocuments.issueId, targetIssueId), + eq(issueDocuments.key, args.target.key), + )) + .then((rows: Array<{ + issueId: string; + documentId: string; + key: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + }>) => rows[0] ?? null); + + if (!row) return null; + if (args.target.documentId && args.target.documentId !== row.documentId) return null; + return row; +} + +function buildIssueDocumentTargetFromSnapshot(args: { + issueId: string; + snapshot: { + issueId: string; + documentId: string; + key: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + } | null; +}): RequestConfirmationTarget | null { + if (!args.snapshot?.latestRevisionId) return null; + return { + type: "issue_document", + issueId: args.snapshot.issueId ?? args.issueId, + documentId: args.snapshot.documentId, + key: args.snapshot.key, + revisionId: args.snapshot.latestRevisionId, + revisionNumber: args.snapshot.latestRevisionNumber, + }; +} + +function buildIssueDocumentTargetFromDocument(args: { + issueId: string; + document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null; +}): RequestConfirmationTarget | null { + if (!args.document?.latestRevisionId) return null; + return { + type: "issue_document", + issueId: args.issueId, + documentId: args.document.id, + key: args.document.key, + revisionId: args.document.latestRevisionId, + revisionNumber: args.document.latestRevisionNumber ?? null, + }; +} + +async function assertRequestConfirmationTargetIsCurrent(db: Db | any, args: { + companyId: string; + issueId: string; + target?: RequestConfirmationTarget | null; +}) { + if (!args.target) return; + if (args.target.type !== "issue_document") return; + const snapshot = await getIssueDocumentTargetSnapshot(db, { + companyId: args.companyId, + issueId: args.issueId, + target: args.target, + }); + if (!snapshot || snapshot.latestRevisionId !== args.target.revisionId) { + throw unprocessable("request_confirmation target must reference the current issue document revision"); + } + if (args.target.revisionNumber && snapshot.latestRevisionNumber !== args.target.revisionNumber) { + throw unprocessable("request_confirmation target revisionNumber must match the current issue document revision"); + } +} + +async function expireStaleRequestConfirmationTarget(db: Db | any, args: { + row: IssueThreadInteractionRow; + actor: InteractionActor; +}): Promise { + if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null; + const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction; + const target = interaction.payload.target ?? null; + if (!target) return null; + if (target.type !== "issue_document") return null; + + const snapshot = await getIssueDocumentTargetSnapshot(db, { + companyId: args.row.companyId, + issueId: args.row.issueId, + target, + }); + const isCurrent = + snapshot + && snapshot.latestRevisionId === target.revisionId + && (!target.revisionNumber || snapshot.latestRevisionNumber === target.revisionNumber); + if (isCurrent) return null; + + const now = new Date(); + const currentTarget = buildIssueDocumentTargetFromSnapshot({ + issueId: args.row.issueId, + snapshot, + }); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + payload: currentTarget + ? { + ...interaction.payload, + target: currentTarget, + } + : interaction.payload, + result: { + version: 1, + outcome: "stale_target", + staleTarget: target, + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, args.row.issueId); + return hydrateInteraction(updated); +} + +export function issueThreadInteractionService(db: Db) { + async function getIdempotentInteraction(args: { + issueId: string; + companyId: string; + idempotencyKey: string; + }) { + return db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, args.companyId), + eq(issueThreadInteractions.issueId, args.issueId), + eq(issueThreadInteractions.idempotencyKey, args.idempotencyKey), + )) + .then((rows) => rows[0] ?? null); + } + + async function getPendingInteractionForResolution(args: { + issue: { id: string; companyId: string }; + interactionId: string; + }) { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, args.interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== args.issue.companyId || current.issueId !== args.issue.id) { + throw notFound("Interaction not found"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + return current; + } + + async function acceptRequestConfirmation(args: { + issue: { id: string; companyId: string }; + current: IssueThreadInteractionRow; + actor: InteractionActor; + }): Promise<{ + interaction: IssueThreadInteraction; + continuationIssue: IssueWakeTarget | null; + }> { + const expired = await expireStaleRequestConfirmationTarget(db, { + row: args.current, + actor: args.actor, + }); + if (expired) { + return { interaction: expired, continuationIssue: null }; + } + + const now = new Date(); + return db.transaction(async (tx) => { + const [updated] = await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + result: { + version: 1, + outcome: "accepted", + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.current.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + const issueContext = await tx + .select({ + id: issues.id, + companyId: issues.companyId, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issues) + .where(eq(issues.id, args.issue.id)) + .then((rows: IssueResolutionContext[]) => rows[0] ?? null); + + if (!issueContext || issueContext.companyId !== args.issue.companyId) { + throw notFound("Issue not found"); + } + + let continuationIssue: IssueWakeTarget | null = null; + if (shouldReturnAcceptedConfirmationToCreatorAgent({ + issue: issueContext, + current: args.current, + actor: args.actor, + })) { + const returnStatus = issueContext.status === "blocked" ? "blocked" : "todo"; + const returnedIssue = await issueService(db).update(args.issue.id, { + status: returnStatus, + assigneeAgentId: args.current.createdByAgentId, + assigneeUserId: null, + actorAgentId: args.actor.agentId ?? null, + actorUserId: args.actor.userId ?? null, + }, tx); + + if (returnedIssue) { + continuationIssue = { + id: returnedIssue.id, + assigneeAgentId: returnedIssue.assigneeAgentId ?? null, + assigneeUserId: returnedIssue.assigneeUserId ?? null, + status: returnedIssue.status, + }; + } + } else { + await touchIssue(tx, args.issue.id); + } + + return { + interaction: hydrateInteraction(updated), + continuationIssue, + }; + }); + } + + async function rejectRequestConfirmation(args: { + issue: { id: string; companyId: string }; + current: IssueThreadInteractionRow; + input: RejectIssueThreadInteraction; + actor: InteractionActor; + }): Promise { + const expired = await expireStaleRequestConfirmationTarget(db, { + row: args.current, + actor: args.actor, + }); + if (expired) { + return expired; + } + + const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction; + const reason = args.input.reason?.trim() ?? ""; + if (interaction.payload.rejectRequiresReason === true && reason.length === 0) { + throw unprocessable("A decline reason is required for this confirmation"); + } + + const now = new Date(); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "rejected", + result: { + version: 1, + outcome: "rejected", + reason: reason || null, + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.current.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, args.issue.id); + return hydrateInteraction(updated); + } + + return { + listForIssue: async (issueId: string) => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.issueId, issueId)) + .orderBy(asc(issueThreadInteractions.createdAt), asc(issueThreadInteractions.id)); + + return rows.map((row) => hydrateInteraction(row)); + }, + + getById: async (interactionId: string) => { + const row = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + return row ? hydrateInteraction(row) : null; + }, + + create: async ( + issue: { id: string; companyId: string }, + input: CreateIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = createIssueThreadInteractionSchema.parse(input); + + if (data.idempotencyKey) { + const existing = await getIdempotentInteraction({ + issueId: issue.id, + companyId: issue.companyId, + idempotencyKey: data.idempotencyKey, + }); + if (existing) { + if (!isEquivalentCreateRequest(existing, data, actor)) { + throw conflict("Interaction idempotency key already exists for a different request", { + idempotencyKey: data.idempotencyKey, + }); + } + return hydrateInteraction(existing); + } + } + + if (data.sourceCommentId) { + const sourceComment = await db + .select({ + companyId: issueComments.companyId, + issueId: issueComments.issueId, + }) + .from(issueComments) + .where(eq(issueComments.id, data.sourceCommentId)) + .then((rows) => rows[0] ?? null); + if (!sourceComment || sourceComment.companyId !== issue.companyId || sourceComment.issueId !== issue.id) { + throw unprocessable("sourceCommentId must belong to the same issue and company"); + } + } + + if (data.sourceRunId) { + const sourceRun = await db + .select({ + companyId: heartbeatRuns.companyId, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, data.sourceRunId)) + .then((rows) => rows[0] ?? null); + if (!sourceRun || sourceRun.companyId !== issue.companyId) { + throw unprocessable("sourceRunId must belong to the same company"); + } + } + + if (data.kind === "request_confirmation") { + await assertRequestConfirmationTargetIsCurrent(db, { + companyId: issue.companyId, + issueId: issue.id, + target: data.payload.target ?? null, + }); + } + + let created: IssueThreadInteractionRow; + try { + [created] = await db + .insert(issueThreadInteractions) + .values({ + companyId: issue.companyId, + issueId: issue.id, + kind: data.kind, + status: "pending", + continuationPolicy: data.continuationPolicy, + idempotencyKey: data.idempotencyKey ?? null, + sourceCommentId: data.sourceCommentId ?? null, + sourceRunId: data.sourceRunId ?? null, + title: data.title ?? null, + summary: data.summary ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + payload: data.payload, + }) + .returning(); + } catch (error) { + if (!data.idempotencyKey || !isIssueThreadInteractionIdempotencyConflict(error)) { + throw error; + } + const existing = await getIdempotentInteraction({ + issueId: issue.id, + companyId: issue.companyId, + idempotencyKey: data.idempotencyKey, + }); + if (!existing) throw error; + if (!isEquivalentCreateRequest(existing, data, actor)) { + throw conflict("Interaction idempotency key already exists for a different request", { + idempotencyKey: data.idempotencyKey, + }); + } + return hydrateInteraction(existing); + } + + await touchIssue(db, issue.id); + return hydrateInteraction(created); + }, + + acceptInteraction: async ( + issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, + interactionId: string, + input: AcceptIssueThreadInteraction, + actor: InteractionActor, + ): Promise => { + const data = acceptIssueThreadInteractionSchema.parse(input); + const current = await getPendingInteractionForResolution({ issue, interactionId }); + switch (current.kind) { + case "suggest_tasks": + return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor); + case "request_confirmation": { + const accepted = await acceptRequestConfirmation({ + issue, + current, + actor, + }); + return { + interaction: accepted.interaction, + continuationIssue: accepted.continuationIssue, + createdIssues: [], + }; + } + default: + throw unprocessable(`Interactions of kind ${current.kind} cannot be accepted`); + } + }, + + acceptSuggestedTasks: async ( + issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, + interactionId: string, + input: AcceptIssueThreadInteraction, + actor: InteractionActor, + ) => { + 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 !== "suggest_tasks") { + throw unprocessable("Only suggest_tasks interactions can be accepted"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const interaction = hydrateInteraction(current) as SuggestTasksInteraction; + const { selectedTasks, skippedClientKeys } = resolveSelectedSuggestedTasks({ + interaction, + selectedClientKeys: input.selectedClientKeys, + }); + const orderedTasks = buildTaskCreationOrder(selectedTasks); + const explicitParentIds = [...new Set([ + issue.id, + ...(interaction.payload.defaultParentId ? [interaction.payload.defaultParentId] : []), + ...selectedTasks + .map((task) => task.parentId ?? null) + .filter((value): value is string => Boolean(value)), + ])]; + + const parentRows = explicitParentIds.length === 0 + ? [] + : await db + .select({ + id: issues.id, + identifier: issues.identifier, + companyId: issues.companyId, + }) + .from(issues) + .where(and(eq(issues.companyId, issue.companyId), inArray(issues.id, explicitParentIds))); + if (parentRows.length !== explicitParentIds.length) { + throw unprocessable("Suggested tasks reference parent issues outside this company or issue tree"); + } + + const parentById = new Map(parentRows.map((row) => [row.id, row] as const)); + const createdByClientKey = new Map(); + const createdWakeTargets: IssueWakeTarget[] = []; + + await db.transaction(async (tx) => { + const resolvedAt = new Date(); + const [claimed] = await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt, + updatedAt: resolvedAt, + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!claimed) { + throw conflict("Interaction has already been resolved"); + } + + for (const task of orderedTasks) { + const parentIssueId = task.parentClientKey + ? createdByClientKey.get(task.parentClientKey)?.issueId ?? null + : task.parentId ?? interaction.payload.defaultParentId ?? issue.id; + if (!parentIssueId) { + throw unprocessable(`Unable to resolve parent for suggested task ${task.clientKey}`); + } + + const { issue: createdIssue } = await issueService(tx as unknown as Db).createChild(parentIssueId, { + title: task.title, + description: task.description ?? null, + status: "todo", + priority: task.priority ?? "medium", + assigneeAgentId: task.assigneeAgentId ?? null, + assigneeUserId: task.assigneeUserId ?? null, + projectId: task.projectId ?? issue.projectId, + goalId: task.goalId ?? issue.goalId, + billingCode: task.billingCode ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + actorAgentId: actor.agentId ?? null, + actorUserId: actor.userId ?? null, + } as Parameters["createChild"]>[1]); + + const parentIdentifier = createdByClientKey.get(task.parentClientKey ?? "")?.identifier + ?? parentById.get(parentIssueId)?.identifier + ?? null; + createdByClientKey.set(task.clientKey, { + clientKey: task.clientKey, + issueId: createdIssue.id, + identifier: createdIssue.identifier ?? null, + title: createdIssue.title, + parentIssueId, + parentIdentifier, + }); + createdWakeTargets.push({ + id: createdIssue.id, + assigneeAgentId: createdIssue.assigneeAgentId ?? null, + status: createdIssue.status, + }); + } + + const [updated] = await tx + .update(issueThreadInteractions) + .set({ + result: { + version: 1, + createdTasks: [...createdByClientKey.values()], + ...(skippedClientKeys.length > 0 ? { skippedClientKeys } : {}), + }, + updatedAt: new Date(), + }) + .where(eq(issueThreadInteractions.id, interactionId)) + .returning(); + + await touchIssue(tx, issue.id); + current.status = updated.status; + current.result = updated.result; + current.resolvedByAgentId = updated.resolvedByAgentId; + current.resolvedByUserId = updated.resolvedByUserId; + current.resolvedAt = updated.resolvedAt; + current.updatedAt = updated.updatedAt; + }); + + return { + interaction: hydrateInteraction(current), + createdIssues: createdWakeTargets, + }; + }, + + rejectInteraction: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RejectIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = rejectIssueThreadInteractionSchema.parse(input); + const current = await getPendingInteractionForResolution({ issue, interactionId }); + switch (current.kind) { + case "suggest_tasks": + return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current); + case "request_confirmation": + return rejectRequestConfirmation({ + issue, + current, + input: data, + actor, + }); + default: + throw unprocessable(`Interactions of kind ${current.kind} cannot be rejected`); + } + }, + + rejectSuggestedTasks: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RejectIssueThreadInteraction, + actor: InteractionActor, + current: IssueThreadInteractionRow, + ) => { + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "suggest_tasks") { + throw unprocessable("Only suggest_tasks interactions can be rejected"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "rejected", + result: { + version: 1, + rejectionReason: input.reason?.trim() || null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .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); + return hydrateInteraction(updated); + }, + + expireRequestConfirmationsSupersededByComment: async ( + issue: { id: string; companyId: string }, + comment: { id: string; authorUserId?: string | null }, + actor: InteractionActor, + ) => { + if (!comment.authorUserId) return []; + + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )); + + const superseded = rows.filter((row) => { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + return interaction.payload.supersedeOnUserComment === true; + }); + + if (superseded.length === 0) return []; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + for (const row of superseded) { + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId: comment.id, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + 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) { + await touchIssue(db, issue.id); + } + return expired; + }, + + expireStaleRequestConfirmationsForIssueDocument: async ( + issue: { id: string; companyId: string }, + document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null, + actor: InteractionActor, + ) => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )); + + const staleRows = rows.filter((row) => { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const target = interaction.payload.target; + if (!target || target.type !== "issue_document") return false; + const targetIssueId = target.issueId ?? issue.id; + if (targetIssueId !== issue.id) return false; + if (document && target.documentId && target.documentId !== document.id) return false; + if (document && target.key !== document.key) return false; + if (!document) return true; + return ( + target.revisionId !== document.latestRevisionId + || (target.revisionNumber != null && target.revisionNumber !== document.latestRevisionNumber) + ); + }); + + if (staleRows.length === 0) return []; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + for (const row of staleRows) { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const target = interaction.payload.target ?? null; + const currentTarget = buildIssueDocumentTargetFromDocument({ + issueId: issue.id, + document, + }); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + payload: currentTarget + ? { + ...interaction.payload, + target: currentTarget, + } + : interaction.payload, + result: { + version: 1, + outcome: "stale_target", + staleTarget: target, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + 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) { + await touchIssue(db, issue.id); + } + return expired; + }, + + answerQuestions: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RespondIssueThreadInteraction, + actor: InteractionActor, + ) => { + 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 !== "ask_user_questions") { + throw unprocessable("Only ask_user_questions interactions can be answered"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const interaction = hydrateInteraction(current) as AskUserQuestionsInteraction; + const normalizedAnswers = normalizeQuestionAnswers({ + questions: interaction.payload.questions, + answers: input.answers, + }); + + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "answered", + result: { + version: 1, + answers: normalizedAnswers, + summaryMarkdown: input.summaryMarkdown ?? null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .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); + return hydrateInteraction(updated); + }, + }; +} diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index f0647192..042e2739 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -77,6 +77,7 @@ const OPERATION_CAPABILITIES: Record = { "issues.requestWakeup": ["issues.wakeup"], "issues.requestWakeups": ["issues.wakeup"], "issue.comments.create": ["issue.comments.create"], + "issue.interactions.create": ["issue.interactions.create"], "activity.log": ["activity.log.write"], "metrics.write": ["metrics.write"], "telemetry.track": ["telemetry.track"], diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 6a8ba88e..9976e04c 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -21,11 +21,12 @@ import type { PluginIssueAssigneeSummary, PluginIssueOrchestrationSummary, } from "@paperclipai/plugin-sdk"; -import type { IssueDocumentSummary } from "@paperclipai/shared"; +import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { issueService } from "./issues.js"; +import { issueThreadInteractionService } from "./issue-thread-interactions.js"; import { goalService } from "./goals.js"; import { documentService } from "./documents.js"; import { heartbeatService } from "./heartbeat.js"; @@ -1506,6 +1507,29 @@ export function buildHostServices( }); return comment; }, + async createInteraction(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const interaction = await issueThreadInteractionService(db).create(issue, params.interaction as CreateIssueThreadInteraction, { + agentId: params.authorAgentId ?? null, + }); + await logPluginActivity({ + companyId, + action: "issue.thread_interaction_created", + entityType: "issue", + entityId: issue.id, + actor: { actorAgentId: params.authorAgentId ?? null }, + details: { + identifier: issue.identifier, + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + continuationPolicy: interaction.continuationPolicy, + }, + }); + return interaction as any; + }, }, issueDocuments: { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index c7e418d4..777dc4ff 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -287,6 +287,8 @@ If the issue identifier is available, prefer the document deep link over a plain If you're asked to make a plan, _do not mark the issue as done_. Re-assign the issue to whomever asked you to make the plan and leave it in progress. +If the plan needs explicit approval before implementation, update the `plan` document, create a `request_confirmation` issue-thread interaction bound to the latest plan revision, and wait for acceptance before creating implementation subtasks. See `references/api-reference.md` for the interaction payload. + Recommended API flow: ```bash @@ -314,6 +316,7 @@ If `plan` already exists, fetch the current document first and send its latest ` | Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | | Get comments / delta / single | `GET /api/issues/:issueId/comments[?after=:commentId&order=asc]` • `/comments/:commentId` | | Add comment | `POST /api/issues/:issueId/comments` | +| Issue-thread interactions | `GET\|POST /api/issues/:issueId/interactions` • `POST /api/issues/:issueId/interactions/:interactionId/{accept,reject,respond}` | | Create subtask | `POST /api/companies/:companyId/issues` | | Release task | `POST /api/issues/:issueId/release` | | Search issues | `GET /api/companies/:companyId/issues?q=search+term` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 0840428c..319324c3 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -637,6 +637,54 @@ POST /api/companies/{companyId}/approvals { "type": "approve_ceo_strategy", "requestedByAgentId": "{your-agent-id}", "payload": { "plan": "..." } } ``` +### Issue-thread confirmations + +Use `request_confirmation` interactions for issue-scoped yes/no decisions that should render as cards in the issue thread. Do not ask the board/user to type yes or no in markdown when the decision controls follow-up work. + +Use formal approvals for governed actions. Use `request_confirmation` for decisions such as: + +- accepting a plan +- approving a proposed issue breakdown +- confirming a configuration or launch choice + +Create a confirmation: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_confirmation", + "idempotencyKey": "confirmation:{issueId}:{targetKey}:{targetVersion}", + "title": "Plan approval", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Accept this plan?", + "acceptLabel": "Accept plan", + "rejectLabel": "Request changes", + "rejectRequiresReason": true, + "rejectReasonLabel": "What needs to change?", + "detailsMarkdown": "Review the latest plan document before accepting.", + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "documentId": "{documentId}", + "key": "plan", + "revisionId": "{latestRevisionId}", + "revisionNumber": 3 + } + } +} +``` + +Rules: + +- `continuationPolicy: "wake_assignee"` wakes the assignee only after a `request_confirmation` is accepted. +- Rejection does not wake the assignee by default. The board/user can add a normal comment when revisions are needed. +- Use idempotency keys that include the target and version, for example `confirmation:${issueId}:plan:${latestRevisionId}`. +- Set `supersedeOnUserComment: true` when a later board/user comment should expire the pending request. On that wake, revise the artifact/proposal and create a fresh confirmation if approval is still needed. +- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, and wait for acceptance before creating implementation subtasks. + ### Checking approval status ``` @@ -739,6 +787,11 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId/comments` | List comments | | 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`) | +| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation | +| POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation | +| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions | | 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) | diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index ba456af9..c2704128 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,4 +1,5 @@ import type { + AskUserQuestionsAnswer, Approval, DocumentRevision, FeedbackTargetType, @@ -9,6 +10,7 @@ import type { IssueComment, IssueDocument, IssueLabel, + IssueThreadInteraction, IssueWorkProduct, UpsertIssueDocument, } from "@paperclipai/shared"; @@ -99,6 +101,24 @@ export const issuesApi = { const qs = params.toString(); return api.get(`/issues/${id}/comments${qs ? `?${qs}` : ""}`); }, + listInteractions: (id: string) => + api.get(`/issues/${id}/interactions`), + createInteraction: (id: string, data: Record) => + api.post(`/issues/${id}/interactions`, data), + acceptInteraction: ( + id: string, + interactionId: string, + data?: { selectedClientKeys?: string[] }, + ) => + api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), + rejectInteraction: (id: string, interactionId: string, reason?: string) => + api.post(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}), + respondToInteraction: ( + id: string, + interactionId: string, + data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null }, + ) => + api.post(`/issues/${id}/interactions/${interactionId}/respond`, data), getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index f00c3f27..c6b163ac 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -12,6 +12,10 @@ import { resolveAssistantMessageFoldedState, resolveIssueChatHumanAuthor, } from "./IssueChatThread"; +import type { + AskUserQuestionsInteraction, + SuggestTasksInteraction, +} from "../lib/issue-thread-interactions"; const { markdownEditorFocusMock } = vi.hoisted(() => ({ markdownEditorFocusMock: vi.fn(), @@ -139,6 +143,78 @@ vi.mock("../hooks/usePaperclipIssueRuntime", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +function createSuggestedTasksInteraction( + overrides: Partial = {}, +): SuggestTasksInteraction { + return { + id: "interaction-suggest-1", + companyId: "company-1", + issueId: "issue-1", + kind: "suggest_tasks", + title: "Suggested follow-up work", + summary: "Preview the next issue tree before accepting it.", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + createdAt: new Date("2026-04-06T12:02:00.000Z"), + updatedAt: new Date("2026-04-06T12:02:00.000Z"), + resolvedAt: null, + payload: { + version: 1, + tasks: [ + { + clientKey: "task-1", + title: "Prototype the card", + }, + ], + }, + result: null, + ...overrides, + }; +} + +function createQuestionInteraction( + overrides: Partial = {}, +): AskUserQuestionsInteraction { + return { + id: "interaction-question-1", + companyId: "company-1", + issueId: "issue-1", + kind: "ask_user_questions", + title: "Clarify the phase", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + createdAt: new Date("2026-04-06T12:03:00.000Z"), + updatedAt: new Date("2026-04-06T12:03:00.000Z"), + resolvedAt: null, + payload: { + version: 1, + submitLabel: "Submit answers", + questions: [ + { + id: "scope", + prompt: "Pick one scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }, + ], + }, + result: null, + ...overrides, + }; +} + describe("IssueChatThread", () => { let container: HTMLDivElement; @@ -300,6 +376,165 @@ describe("IssueChatThread", () => { }); }); + it("invokes the accept callback for pending suggested-task interactions", async () => { + const root = createRoot(container); + const onAcceptInteraction = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onAcceptInteraction={onAcceptInteraction} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const acceptButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Accept drafts"), + ); + expect(acceptButton).toBeTruthy(); + + await act(async () => { + acceptButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onAcceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-suggest-1", + kind: "suggest_tasks", + }), + ["task-1"], + ); + + act(() => { + root.unmount(); + }); + }); + + it("submits only the selected draft subtree when tasks are manually pruned", async () => { + const root = createRoot(container); + const onAcceptInteraction = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onAcceptInteraction={onAcceptInteraction} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const childCheckbox = container.querySelector('[aria-label="Include Child task"]'); + expect(childCheckbox).toBeTruthy(); + + await act(async () => { + childCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const acceptButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Accept selected drafts"), + ); + expect(acceptButton).toBeTruthy(); + await act(async () => { + acceptButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onAcceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-suggest-1", + kind: "suggest_tasks", + }), + ["root"], + ); + + act(() => { + root.unmount(); + }); + }); + + it("submits selected answers for pending question interactions", async () => { + const root = createRoot(container); + const onSubmitInteractionAnswers = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onSubmitInteractionAnswers={onSubmitInteractionAnswers} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const optionButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Phase 1"), + ); + const submitButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Submit answers"), + ); + expect(optionButton).toBeTruthy(); + expect(submitButton).toBeTruthy(); + + await act(async () => { + optionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSubmitInteractionAnswers).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-question-1", + kind: "ask_user_questions", + }), + [{ questionId: "scope", optionIds: ["phase-1"] }], + ); + + act(() => { + root.unmount(); + }); + }); + it("renders the transcript directly from stable Paperclip messages", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 3febbc05..058bf5ad 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -45,6 +45,14 @@ import { type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; +import type { + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + IssueThreadInteraction, + RequestConfirmationInteraction, + SuggestTasksInteraction, +} from "../lib/issue-thread-interactions"; +import { isIssueThreadInteraction } from "../lib/issue-thread-interactions"; import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; @@ -67,6 +75,7 @@ import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; +import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; import { AgentIcon } from "./AgentIconPicker"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { @@ -114,6 +123,18 @@ interface IssueChatMessageContext { onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + selectedClientKeys?: string[], + ) => Promise | void; + onRejectInteraction?: ( + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + reason?: string, + ) => Promise | void; + onSubmitInteractionAnswers?: ( + interaction: AskUserQuestionsInteraction, + answers: AskUserQuestionsAnswer[], + ) => Promise | void; } const IssueChatCtx = createContext({ @@ -211,6 +232,7 @@ interface IssueChatComposerProps { interface IssueChatThreadProps { comments: IssueChatComment[]; + interactions?: IssueThreadInteraction[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; @@ -256,6 +278,18 @@ interface IssueChatThreadProps { interruptingQueuedRunId?: string | null; stoppingRunId?: string | null; onImageClick?: (src: string) => void; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + selectedClientKeys?: string[], + ) => Promise | void; + onRejectInteraction?: ( + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + reason?: string, + ) => Promise | void; + onSubmitInteractionAnswers?: ( + interaction: AskUserQuestionsInteraction, + answers: AskUserQuestionsAnswer[], + ) => Promise | void; composerRef?: Ref; } @@ -1698,7 +1732,14 @@ function IssueChatFeedbackButtons({ } function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { - const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx); + const { + agentMap, + currentUserId, + userLabelMap, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, + } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const runId = typeof custom.runId === "string" ? custom.runId : null; @@ -1717,6 +1758,27 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { to: IssueTimelineAssignee; } : null; + const interaction = isIssueThreadInteraction(custom.interaction) + ? custom.interaction + : null; + + if (custom.kind === "interaction" && interaction) { + return ( +
+
+ +
+
+ ); + } if (custom.kind === "event" && actorName) { const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId; @@ -2077,6 +2139,7 @@ const IssueChatComposer = forwardRef buildIssueChatMessages({ comments, + interactions, timelineEvents, linkedRuns, liveRuns, @@ -2188,6 +2255,7 @@ export function IssueChatThread({ }), [ comments, + interactions, timelineEvents, linkedRuns, liveRuns, @@ -2256,7 +2324,14 @@ export function IssueChatThread({ useEffect(() => { const hash = location.hash; - if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return; + if ( + !( + hash.startsWith("#comment-") + || hash.startsWith("#activity-") + || hash.startsWith("#run-") + || hash.startsWith("#interaction-") + ) + ) return; if (messages.length === 0 || hasScrolledRef.current) return; const targetId = hash.slice(1); const element = document.getElementById(targetId); @@ -2286,6 +2361,9 @@ export function IssueChatThread({ onCancelQueued, interruptingQueuedRunId, onImageClick, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, }), [ feedbackVoteByTargetId, @@ -2303,6 +2381,9 @@ export function IssueChatThread({ onCancelQueued, interruptingQueuedRunId, onImageClick, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, ], ); diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx new file mode 100644 index 00000000..fe9d14d7 --- /dev/null +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; +import { ThemeProvider } from "../context/ThemeContext"; +import { TooltipProvider } from "./ui/tooltip"; +import { + pendingAskUserQuestionsInteraction, + commentExpiredRequestConfirmationInteraction, + disabledDeclineReasonRequestConfirmationInteraction, + failedRequestConfirmationInteraction, + pendingRequestConfirmationInteraction, + pendingSuggestedTasksInteraction, + staleTargetRequestConfirmationInteraction, + rejectedSuggestedTasksInteraction, +} from "../fixtures/issueThreadInteractionFixtures"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => ( + {children} + ), +})); + +function renderCard( + props: Partial> = {}, +) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + + + + + , + ); + }); + + return container; +} + +afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + container?.remove(); + root = null; + container = null; +}); + +describe("IssueThreadInteractionCard", () => { + it("exposes pending question options as selectable radio and checkbox controls", () => { + const host = renderCard({ + interaction: pendingAskUserQuestionsInteraction, + onSubmitInteractionAnswers: vi.fn(), + }); + + const singleGroup = host.querySelector('[role="radiogroup"]'); + expect(singleGroup?.getAttribute("aria-labelledby")).toBe( + "interaction-questions-default-collapse-depth-prompt", + ); + + const radios = [...host.querySelectorAll('[role="radio"]')]; + expect(radios).toHaveLength(2); + expect(radios[0]?.getAttribute("aria-checked")).toBe("false"); + + act(() => { + (radios[0] as HTMLButtonElement).click(); + }); + + expect(radios[0]?.getAttribute("aria-checked")).toBe("true"); + expect(radios[1]?.getAttribute("aria-checked")).toBe("false"); + + const multiGroup = host.querySelector('[role="group"]'); + expect(multiGroup?.getAttribute("aria-labelledby")).toBe( + "interaction-questions-default-post-submit-summary-prompt", + ); + expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3); + }); + + it("makes child tasks explicit in suggested task trees", () => { + const host = renderCard({ + interaction: pendingSuggestedTasksInteraction, + }); + + expect(host.textContent).toContain("Child task"); + }); + + it("shows an explicit placeholder when a rejected interaction has no reason", () => { + const host = renderCard({ + interaction: { + ...rejectedSuggestedTasksInteraction, + result: { version: 1 }, + }, + }); + + expect(host.textContent).toContain("No reason provided."); + }); + + it("requires a decline reason when the request confirmation payload asks for one", async () => { + const onRejectInteraction = vi.fn(async () => undefined); + const host = renderCard({ + interaction: pendingRequestConfirmationInteraction, + onRejectInteraction, + }); + + const declineButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Request revisions"), + ); + expect(declineButton).toBeTruthy(); + + await act(async () => { + declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const saveButton = Array.from(host.querySelectorAll("button")).filter((button) => + button.textContent?.includes("Request revisions"), + ).at(-1); + expect(saveButton?.hasAttribute("disabled")).toBe(false); + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(host.textContent).toContain("A decline reason is required."); + + const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null; + expect(textarea).toBeTruthy(); + expect(textarea?.getAttribute("aria-invalid")).toBe("true"); + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(textarea, "Needs a smaller phase split"); + textarea!.dispatchEvent(new Event("input", { bubbles: true })); + }); + const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) => + button.textContent?.includes("Request revisions"), + ).at(-1); + expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false); + await act(async () => { + enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onRejectInteraction).toHaveBeenCalledWith( + expect.objectContaining({ kind: "request_confirmation" }), + "Needs a smaller phase split", + ); + }); + + it("invokes the confirm callback with pending request confirmations", async () => { + const onAcceptInteraction = vi.fn(async () => undefined); + const host = renderCard({ + interaction: pendingRequestConfirmationInteraction, + onAcceptInteraction, + }); + + const confirmButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Approve plan"), + ); + expect(confirmButton).toBeTruthy(); + + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onAcceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ kind: "request_confirmation" }), + ); + }); + + it("labels accept-only continuation policies in the card header", () => { + const host = renderCard({ + interaction: { + ...pendingRequestConfirmationInteraction, + continuationPolicy: "wake_assignee_on_accept", + }, + }); + + expect(host.textContent).toContain("Wakes on confirm"); + }); + + it("renders request confirmation target links and stale-target expiry", () => { + const host = renderCard({ + interaction: staleTargetRequestConfirmationInteraction, + }); + + const targetLinks = host.querySelectorAll("a"); + expect(host.textContent).toContain("Expired by target change"); + expect(host.textContent).toContain("Plan v3"); + expect(host.textContent).toContain("Plan v4"); + expect(targetLinks[0]?.getAttribute("href")).toContain("#document-plan"); + expect(targetLinks[1]?.getAttribute("href")).toContain("#document-plan"); + expect(host.textContent).not.toContain("Approve plan"); + }); + + it("renders a jump link for confirmations expired by comment", () => { + const host = renderCard({ + interaction: commentExpiredRequestConfirmationInteraction, + }); + + const jumpLink = Array.from(host.querySelectorAll("a")).find((link) => + link.textContent?.includes("Jump to comment"), + ); + + expect(jumpLink?.getAttribute("href")).toBe( + "#comment-22222222-2222-4222-8222-222222222222", + ); + }); + + it("declines immediately when decline reasons are disabled", async () => { + const onRejectInteraction = vi.fn(async () => undefined); + const host = renderCard({ + interaction: disabledDeclineReasonRequestConfirmationInteraction, + onRejectInteraction, + }); + + const declineButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Keep it"), + ); + expect(declineButton).toBeTruthy(); + + await act(async () => { + declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(host.querySelector("textarea")).toBeNull(); + expect(onRejectInteraction).toHaveBeenCalledWith( + expect.objectContaining({ kind: "request_confirmation" }), + undefined, + ); + }); + + it("renders explicit copy for failed request confirmations", () => { + const host = renderCard({ + interaction: failedRequestConfirmationInteraction, + }); + + expect(host.textContent).toContain( + "This request could not be resolved. Try again or create a new request.", + ); + }); +}); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx new file mode 100644 index 00000000..c65f8b4f --- /dev/null +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -0,0 +1,1268 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Agent } from "@paperclipai/shared"; +import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react"; +import { Link } from "@/lib/router"; +import { formatAssigneeUserLabel } from "../lib/assignees"; +import { + buildSuggestedTaskTree, + collectSuggestedTaskClientKeys, + countSuggestedTaskNodes, + getQuestionAnswerLabels, + type AskUserQuestionsAnswer, + type AskUserQuestionsInteraction, + type IssueThreadInteraction, + type RequestConfirmationInteraction, + type RequestConfirmationTarget, + type SuggestTasksInteraction, + type SuggestTasksResultCreatedTask, + type SuggestedTaskDraft, + type SuggestedTaskTreeNode, +} from "../lib/issue-thread-interactions"; +import { cn, formatDateTime, formatShortDate } from "../lib/utils"; +import { MarkdownBody } from "./MarkdownBody"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { PriorityIcon } from "./PriorityIcon"; +import { Textarea } from "./ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +interface IssueThreadInteractionCardProps { + interaction: IssueThreadInteraction; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + selectedClientKeys?: string[], + ) => Promise | void; + onRejectInteraction?: ( + interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + reason?: string, + ) => Promise | void; + onSubmitInteractionAnswers?: ( + interaction: AskUserQuestionsInteraction, + answers: AskUserQuestionsAnswer[], + ) => Promise | void; +} + +function resolveActorLabel(args: { + agentId?: string | null; + userId?: string | null; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; +}) { + const { agentId, userId, agentMap, currentUserId, userLabelMap } = args; + if (agentId) { + return agentMap?.get(agentId)?.name ?? agentId.slice(0, 8); + } + if (userId) { + return formatAssigneeUserLabel(userId, currentUserId, userLabelMap) ?? "Board"; + } + return "Unknown"; +} + +function statusLabel(status: IssueThreadInteraction["status"]) { + switch (status) { + case "pending": + return "Pending"; + case "accepted": + return "Accepted"; + case "rejected": + return "Rejected"; + case "answered": + return "Answered"; + case "expired": + return "Expired"; + case "failed": + return "Failed"; + default: + return status; + } +} + +function interactionKindLabel(kind: IssueThreadInteraction["kind"]) { + switch (kind) { + case "suggest_tasks": + return "Suggested tasks"; + case "ask_user_questions": + return "Ask user questions"; + case "request_confirmation": + return "Confirmation"; + default: + return kind; + } +} + +function statusIcon(status: IssueThreadInteraction["status"]) { + switch (status) { + case "accepted": + case "answered": + return CheckCircle2; + case "rejected": + case "failed": + return XCircle; + case "expired": + return AlertTriangle; + default: + return CircleDashed; + } +} + +function statusClasses(status: IssueThreadInteraction["status"]) { + switch (status) { + case "accepted": + case "answered": + return { + shell: "border-emerald-400/70 bg-transparent", + badge: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100", + }; + case "rejected": + return { + shell: "border-rose-400/70 bg-transparent", + badge: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100", + }; + case "failed": + case "expired": + return { + shell: "border-amber-400/70 bg-transparent", + badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100", + }; + default: + return { + shell: "border-sky-500/70 bg-transparent", + badge: "border-sky-500/70 bg-sky-500/10 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100", + }; + } +} + +function TaskField({ + label, + value, + tone = "default", +}: { + label: string; + value: string; + tone?: "default" | "subtle"; +}) { + return ( + + {label}: {value} + + ); +} + +function createdTaskMap( + createdTasks: readonly SuggestTasksResultCreatedTask[] | undefined, +) { + return new Map( + (createdTasks ?? []).map((entry) => [entry.clientKey, entry] as const), + ); +} + +function TaskTreeNode({ + node, + createdByClientKey, + agentMap, + currentUserId, + userLabelMap, + depth = 0, + selectedClientKeys, + skippedClientKeys, + showSelection, + onToggleSelection, +}: { + node: SuggestedTaskTreeNode; + createdByClientKey: ReadonlyMap; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + depth?: number; + selectedClientKeys?: ReadonlySet; + skippedClientKeys?: ReadonlySet; + showSelection?: boolean; + onToggleSelection?: (node: SuggestedTaskTreeNode, checked: boolean) => void; +}) { + const visibleChildren = node.children.filter((child) => !child.task.hiddenInPreview); + const hiddenChildCount = node.children + .filter((child) => child.task.hiddenInPreview) + .reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0); + const createdTask = createdByClientKey.get(node.task.clientKey); + const isSelected = selectedClientKeys?.has(node.task.clientKey) ?? false; + const isSkipped = skippedClientKeys?.has(node.task.clientKey) ?? false; + const assigneeLabel = resolveActorLabel({ + agentId: node.task.assigneeAgentId, + userId: node.task.assigneeUserId, + agentMap, + currentUserId, + userLabelMap, + }); + const hasExplicitAssignee = Boolean( + node.task.assigneeAgentId || node.task.assigneeUserId, + ); + const labels = node.task.labels ?? []; + const hasMetadata = hasExplicitAssignee + || Boolean(node.task.billingCode) + || Boolean(node.task.projectId) + || labels.length > 0; + + return ( + <> +
0 && "before:absolute before:left-3 before:top-0 before:h-full before:w-px before:bg-border/70", + )} + style={depth > 0 ? { paddingLeft: `${depth * 24 + 12}px` } : undefined} + > +
+
+
+ {showSelection ? ( + onToggleSelection?.(node, checked === true)} + aria-label={`Include ${node.task.title}`} + className="mt-0.5" + /> + ) : null} +
+
+ {node.task.priority ? ( + + ) : null} +
+ {node.task.title} +
+
+ {depth > 0 ? ( +
+ Child task +
+ ) : null} + {node.task.description ? ( +

+ {node.task.description} +

+ ) : null} +
+
+
+ + {createdTask?.issueId ? ( + + {createdTask.identifier ?? createdTask.issueId.slice(0, 8)} + + + ) : isSkipped ? ( + + Skipped + + ) : null} +
+ + {hasMetadata ? ( +
+ {hasExplicitAssignee ? ( + + ) : null} + {node.task.billingCode ? ( + + ) : null} + {node.task.projectId ? ( + + ) : null} + {labels.map((label) => ( + + ))} +
+ ) : null} + + {hiddenChildCount > 0 ? ( +
+ + + {hiddenChildCount === 1 + ? "1 follow-on task hidden in preview" + : `${hiddenChildCount} follow-on tasks hidden in preview`} + +
+ ) : null} +
+ + {visibleChildren.length > 0 ? ( + <> + {visibleChildren.map((child) => ( + + ))} + + ) : null} + + ); +} + +function SuggestTasksCard({ + interaction, + agentMap, + currentUserId, + userLabelMap, + onAcceptInteraction, + onRejectInteraction, +}: { + interaction: SuggestTasksInteraction; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + onAcceptInteraction?: ( + interaction: SuggestTasksInteraction, + selectedClientKeys?: string[], + ) => Promise | void; + onRejectInteraction?: ( + interaction: SuggestTasksInteraction, + reason?: string, + ) => Promise | void; +}) { + const [rejecting, setRejecting] = useState(false); + const [working, setWorking] = useState<"accept" | "reject" | null>(null); + const [rejectReason, setRejectReason] = useState( + interaction.result?.rejectionReason ?? "", + ); + + useEffect(() => { + setRejectReason(interaction.result?.rejectionReason ?? ""); + if (interaction.status !== "pending") { + setRejecting(false); + setWorking(null); + } + }, [interaction.result?.rejectionReason, interaction.status]); + + const roots = useMemo( + () => + buildSuggestedTaskTree(interaction.payload.tasks).filter( + (node) => !node.task.hiddenInPreview, + ), + [interaction.payload.tasks], + ); + const createdByClientKey = useMemo( + () => createdTaskMap(interaction.result?.createdTasks), + [interaction.result?.createdTasks], + ); + const skippedClientKeys = useMemo( + () => new Set(interaction.result?.skippedClientKeys ?? []), + [interaction.result?.skippedClientKeys], + ); + const totalTasks = interaction.payload.tasks.length; + const [selectedClientKeys, setSelectedClientKeys] = useState>( + () => new Set(interaction.payload.tasks.map((task) => task.clientKey)), + ); + const taskSelectionSeed = useMemo( + () => interaction.payload.tasks.map((task) => task.clientKey).join("\n"), + [interaction.payload.tasks], + ); + + useEffect(() => { + setSelectedClientKeys(new Set(interaction.payload.tasks.map((task) => task.clientKey))); + }, [interaction.id, interaction.status, taskSelectionSeed]); + + const taskByClientKey = useMemo( + () => new Map(interaction.payload.tasks.map((task) => [task.clientKey, task] as const)), + [interaction.payload.tasks], + ); + const selectedCount = selectedClientKeys.size; + const createdCount = interaction.result?.createdTasks?.length ?? 0; + const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0; + + async function handleAccept() { + if (!onAcceptInteraction) return; + setWorking("accept"); + try { + await onAcceptInteraction(interaction, [...selectedClientKeys]); + } finally { + setWorking(null); + } + } + + async function handleReject() { + if (!onRejectInteraction) return; + setWorking("reject"); + try { + await onRejectInteraction(interaction, rejectReason.trim() || undefined); + setRejecting(false); + } finally { + setWorking(null); + } + } + + function handleToggleSelection(node: SuggestedTaskTreeNode, checked: boolean) { + const subtreeClientKeys = collectSuggestedTaskClientKeys(node); + setSelectedClientKeys((current) => { + const next = new Set(current); + if (!checked) { + for (const clientKey of subtreeClientKeys) { + next.delete(clientKey); + } + return next; + } + + for (const clientKey of subtreeClientKeys) { + next.add(clientKey); + } + + let parentClientKey = taskByClientKey.get(node.task.clientKey)?.parentClientKey ?? null; + while (parentClientKey) { + next.add(parentClientKey); + parentClientKey = taskByClientKey.get(parentClientKey)?.parentClientKey ?? null; + } + return next; + }); + } + + return ( +
+
+ {totalTasks === 1 ? "1 draft issue" : `${totalTasks} draft issues`} + {interaction.payload.defaultParentId ? ( + + ) : null} +
+ +
+ {roots.map((root) => ( + + ))} +
+ + {interaction.status === "accepted" ? ( +
+
+ Resolution summary +
+

+ {skippedCount > 0 + ? `Created ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"} and skipped ${skippedCount} during review.` + : `Created all ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"}.`} +

+
+ ) : null} + + {interaction.status === "rejected" ? ( +
+
+ Rejection reason +
+

+ {interaction.result?.rejectionReason || "No reason provided."} +

+
+ ) : null} + + {interaction.status === "pending" ? ( +
+
+
+ + {selectedCount === totalTasks + ? `All ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected` + : `${selectedCount} of ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected`} + + {selectedCount < totalTasks ? ( + + {totalTasks - selectedCount} will be skipped if you accept this interaction. + + ) : null} +
+ +
+ + + {selectedCount < totalTasks ? ( + + ) : null} +
+
+ + {rejecting ? ( +
+