mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[codex] Add structured issue-thread interactions (#4244)
## 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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\"}.",
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
54
packages/db/src/schema/issue_thread_interactions.ts
Normal file
54
packages/db/src/schema/issue_thread_interactions.ts
Normal file
@@ -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<IssueThreadInteractionPayload>().notNull(),
|
||||
result: jsonb("result").$type<IssueThreadInteractionResult>(),
|
||||
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),
|
||||
}),
|
||||
);
|
||||
@@ -63,6 +63,9 @@ Write tools:
|
||||
- `paperclipCheckoutIssue`
|
||||
- `paperclipReleaseIssue`
|
||||
- `paperclipAddComment`
|
||||
- `paperclipSuggestTasks`
|
||||
- `paperclipAskUserQuestions`
|
||||
- `paperclipRequestConfirmation`
|
||||
- `paperclipUpsertIssueDocument`
|
||||
- `paperclipRestoreIssueDocumentRevision`
|
||||
- `paperclipControlIssueWorkspaceServices`
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -184,6 +184,7 @@ export interface HostServices {
|
||||
getOrchestrationSummary(params: WorkerToHostMethods["issues.summaries.getOrchestration"][0]): Promise<WorkerToHostMethods["issues.summaries.getOrchestration"][1]>;
|
||||
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
|
||||
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
|
||||
createInteraction(params: WorkerToHostMethods["issues.createInteraction"][0]): Promise<WorkerToHostMethods["issues.createInteraction"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */
|
||||
@@ -342,6 +343,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"issues.summaries.getOrchestration": "issues.orchestration.read",
|
||||
"issues.listComments": "issue.comments.read",
|
||||
"issues.createComment": "issue.comments.create",
|
||||
"issues.createInteraction": "issue.interactions.create",
|
||||
|
||||
// Issue Documents
|
||||
"issues.documents.list": "issue.documents.read",
|
||||
@@ -575,6 +577,9 @@ export function createHostClientHandlers(
|
||||
"issues.createComment": gated("issues.createComment", async (params) => {
|
||||
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) => {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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<string, Issue>();
|
||||
const blockedByIssueIds = new Map<string, string[]>();
|
||||
const issueComments = new Map<string, IssueComment[]>();
|
||||
const issueInteractions = new Map<string, IssueThreadInteraction[]>();
|
||||
const issueDocuments = new Map<string, IssueDocument>();
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
@@ -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<any>;
|
||||
},
|
||||
async askUserQuestions(issueId, interaction, companyId, options) {
|
||||
return this.createInteraction(issueId, { ...interaction, kind: "ask_user_questions" }, companyId, options) as Promise<any>;
|
||||
},
|
||||
async requestConfirmation(issueId, interaction, companyId, options) {
|
||||
return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>;
|
||||
},
|
||||
documents: {
|
||||
async list(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
||||
|
||||
@@ -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<IssueComment>;
|
||||
createInteraction(
|
||||
issueId: string,
|
||||
interaction: CreateIssueThreadInteraction,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<IssueThreadInteraction>;
|
||||
suggestTasks(
|
||||
issueId: string,
|
||||
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "suggest_tasks" }>, "kind">,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<SuggestTasksInteraction>;
|
||||
askUserQuestions(
|
||||
issueId: string,
|
||||
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "ask_user_questions" }>, "kind">,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<AskUserQuestionsInteraction>;
|
||||
requestConfirmation(
|
||||
issueId: string,
|
||||
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "request_confirmation" }>, "kind">,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<RequestConfirmationInteraction>;
|
||||
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
|
||||
documents: PluginIssueDocumentsClient;
|
||||
/** Read and write blocker relationships. */
|
||||
|
||||
@@ -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<SuggestTasksInteraction> {
|
||||
return callHost("issues.createInteraction", {
|
||||
issueId,
|
||||
companyId,
|
||||
interaction: {
|
||||
...interaction,
|
||||
kind: "suggest_tasks",
|
||||
},
|
||||
authorAgentId: options?.authorAgentId,
|
||||
}) as Promise<SuggestTasksInteraction>;
|
||||
},
|
||||
|
||||
async askUserQuestions(
|
||||
issueId: string,
|
||||
interaction,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<AskUserQuestionsInteraction> {
|
||||
return callHost("issues.createInteraction", {
|
||||
issueId,
|
||||
companyId,
|
||||
interaction: {
|
||||
...interaction,
|
||||
kind: "ask_user_questions",
|
||||
},
|
||||
authorAgentId: options?.authorAgentId,
|
||||
}) as Promise<AskUserQuestionsInteraction>;
|
||||
},
|
||||
|
||||
async requestConfirmation(
|
||||
issueId: string,
|
||||
interaction,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<RequestConfirmationInteraction> {
|
||||
return callHost("issues.createInteraction", {
|
||||
issueId,
|
||||
companyId,
|
||||
interaction: {
|
||||
...interaction,
|
||||
kind: "request_confirmation",
|
||||
},
|
||||
authorAgentId: options?.authorAgentId,
|
||||
}) as Promise<RequestConfirmationInteraction>;
|
||||
},
|
||||
|
||||
documents: {
|
||||
async list(issueId: string, companyId: string) {
|
||||
return callHost("issues.documents.list", { issueId, companyId });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
123
packages/shared/src/issue-thread-interactions.test.ts
Normal file
123
packages/shared/src/issue-thread-interactions.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof addIssueCommentSchema>;
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<typeof createIssueThreadInteractionSchema>;
|
||||
|
||||
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<string>();
|
||||
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<typeof acceptIssueThreadInteractionSchema>;
|
||||
|
||||
export const rejectIssueThreadInteractionSchema = z.object({
|
||||
reason: z.string().trim().max(4000).optional(),
|
||||
});
|
||||
export type RejectIssueThreadInteraction = z.infer<typeof rejectIssueThreadInteractionSchema>;
|
||||
|
||||
export const respondIssueThreadInteractionSchema = z.object({
|
||||
answers: z.array(askUserQuestionsAnswerSchema).max(20),
|
||||
summaryMarkdown: z.string().max(20000).nullable().optional(),
|
||||
});
|
||||
export type RespondIssueThreadInteraction = z.infer<typeof respondIssueThreadInteractionSchema>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../adapters/registry.js")>("../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);
|
||||
|
||||
@@ -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<Express.Request["actor"]> = {}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -53,18 +80,33 @@ function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
|
||||
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<typeof import("../adapters/index.js")>("../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<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
|
||||
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) {
|
||||
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../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({
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/approvals.js")>("../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<string, unknown> = {}) {
|
||||
|
||||
async function createAgentApp() {
|
||||
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/approvals.js")>("../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",
|
||||
|
||||
@@ -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<typeof createStorageService>) {
|
||||
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>(
|
||||
"../routes/assets.js",
|
||||
);
|
||||
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>("../routes/assets.js");
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
@@ -108,7 +109,12 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
|
||||
@@ -55,9 +75,17 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<T>(
|
||||
return latest ?? null;
|
||||
}
|
||||
|
||||
async function waitForHeartbeatIdle(
|
||||
db: ReturnType<typeof createDb>,
|
||||
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<typeof createDb>,
|
||||
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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
actor: Record<string, unknown> = { type: "anon" },
|
||||
) {
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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" },
|
||||
);
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(db: Record<string, unknown>) {
|
||||
let currentAccessModule: Awaited<ReturnType<typeof vi.importActual<typeof import("../routes/access.js")>>> | null = null;
|
||||
|
||||
async function createApp(
|
||||
db: Record<string, unknown>,
|
||||
network: {
|
||||
lookup: ReturnType<typeof vi.fn>;
|
||||
requestHead: ReturnType<typeof vi.fn>;
|
||||
},
|
||||
) {
|
||||
const [access, middleware] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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<string, unknown>) {
|
||||
});
|
||||
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")
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -146,7 +175,11 @@ function makeAgent(id: string, overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
@@ -129,6 +162,19 @@ function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
584
server/src/__tests__/issue-thread-interaction-routes.test.ts
Normal file
584
server/src/__tests__/issue-thread-interaction-routes.test.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown> = {
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
881
server/src/__tests__/issue-thread-interactions-service.test.ts
Normal file
881
server/src/__tests__/issue-thread-interactions-service.test.ts
Normal file
@@ -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<typeof createDb>;
|
||||
let issuesSvc!: ReturnType<typeof issueService>;
|
||||
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -110,20 +160,61 @@ function makeIssue(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
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",
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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<string, unknown>, db: Record<string, unknown>) {
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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<string, unknown>, db: Record<string, unkn
|
||||
}
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
@@ -43,8 +47,8 @@ async function createApp(
|
||||
} = {},
|
||||
) {
|
||||
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/plugins.js"),
|
||||
import("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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();
|
||||
|
||||
@@ -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["apiRoutes"]>): 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<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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({
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
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",
|
||||
|
||||
@@ -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<typeof import("../routes/user-profiles.js")>("../routes/user-profiles.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../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) => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -2299,6 +2299,35 @@ async function resolveInviteResolutionTarget(
|
||||
url: URL
|
||||
): Promise<ResolvedInviteResolutionTarget> {
|
||||
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");
|
||||
|
||||
@@ -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<typeof heartbeatService>;
|
||||
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<typeof getActorInfo>;
|
||||
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<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
||||
|
||||
@@ -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";
|
||||
|
||||
215
server/src/services/issue-thread-interactions.test.ts
Normal file
215
server/src/services/issue-thread-interactions.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
function createSelectChain(rows: SelectRow[]) {
|
||||
return {
|
||||
from() {
|
||||
return {
|
||||
where() {
|
||||
return {
|
||||
then(callback: (rows: SelectRow[]) => unknown) {
|
||||
return Promise.resolve(callback(rows));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeDb(args: {
|
||||
interactionRow: Record<string, unknown>;
|
||||
parentRows?: SelectRow[];
|
||||
}) {
|
||||
let interactionRow = { ...args.interactionRow };
|
||||
const issueTouches: Array<Record<string, unknown>> = [];
|
||||
const interactionUpdates: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown>) {
|
||||
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<void>) => 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);
|
||||
});
|
||||
});
|
||||
1152
server/src/services/issue-thread-interactions.ts
Normal file
1152
server/src/services/issue-thread-interactions.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
listInteractions: (id: string) =>
|
||||
api.get<IssueThreadInteraction[]>(`/issues/${id}/interactions`),
|
||||
createInteraction: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions`, data),
|
||||
acceptInteraction: (
|
||||
id: string,
|
||||
interactionId: string,
|
||||
data?: { selectedClientKeys?: string[] },
|
||||
) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
|
||||
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
|
||||
respondToInteraction: (
|
||||
id: string,
|
||||
interactionId: string,
|
||||
data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null },
|
||||
) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/respond`, data),
|
||||
getComment: (id: string, commentId: string) =>
|
||||
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||
|
||||
@@ -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> = {},
|
||||
): 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> = {},
|
||||
): 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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
interactions={[createSuggestedTasksInteraction()]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
interactions={[createSuggestedTasksInteraction({
|
||||
payload: {
|
||||
version: 1,
|
||||
tasks: [
|
||||
{
|
||||
clientKey: "root",
|
||||
title: "Root task",
|
||||
},
|
||||
{
|
||||
clientKey: "child",
|
||||
parentClientKey: "root",
|
||||
title: "Child task",
|
||||
},
|
||||
],
|
||||
},
|
||||
})]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
interactions={[createQuestionInteraction()]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
@@ -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> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 (
|
||||
<div id={anchorId}>
|
||||
<div className="py-1.5">
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (custom.kind === "event" && actorName) {
|
||||
const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
|
||||
@@ -2077,6 +2139,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
|
||||
export function IssueChatThread({
|
||||
comments,
|
||||
interactions = [],
|
||||
feedbackVotes = [],
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
@@ -2118,6 +2181,9 @@ export function IssueChatThread({
|
||||
interruptingQueuedRunId = null,
|
||||
stoppingRunId = null,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
composerRef,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
@@ -2173,6 +2239,7 @@ export function IssueChatThread({
|
||||
() =>
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
258
ui/src/components/IssueThreadInteractionCard.test.tsx
Normal file
258
ui/src/components/IssueThreadInteractionCard.test.tsx
Normal file
@@ -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 }) => (
|
||||
<a href={to} className={className}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
function renderCard(
|
||||
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
|
||||
) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingAskUserQuestionsInteraction}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
1268
ui/src/components/IssueThreadInteractionCard.tsx
Normal file
1268
ui/src/components/IssueThreadInteractionCard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -681,6 +681,9 @@ function invalidateActivityQueries(
|
||||
if (action === "issue.comment_added") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions });
|
||||
}
|
||||
if (action?.startsWith("issue.thread_interaction_")) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), ...invalidationOptions });
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
537
ui/src/fixtures/issueThreadInteractionFixtures.ts
Normal file
537
ui/src/fixtures/issueThreadInteractionFixtures.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
IssueChatTranscriptEntry,
|
||||
} from "../lib/issue-chat-messages";
|
||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
|
||||
export const issueThreadInteractionFixtureMeta = {
|
||||
companyId: "company-storybook",
|
||||
projectId: "project-board-ui",
|
||||
issueId: "issue-thread-interactions",
|
||||
currentUserId: "user-board",
|
||||
} as const;
|
||||
|
||||
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
|
||||
const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z");
|
||||
return {
|
||||
id: "comment-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
body: "",
|
||||
createdAt,
|
||||
updatedAt: overrides.updatedAt ?? createdAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSuggestTasksInteraction(
|
||||
overrides: Partial<SuggestTasksInteraction>,
|
||||
): SuggestTasksInteraction {
|
||||
return {
|
||||
id: "interaction-suggest-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "suggest_tasks",
|
||||
title: "Suggested issue tree for the first interaction pass",
|
||||
summary:
|
||||
"Draft task creation stays pending until a reviewer accepts it, so the thread can preview structure without mutating the task system.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-20T14:11:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:11:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
defaultParentId: "PAP-1709",
|
||||
tasks: [
|
||||
{
|
||||
clientKey: "root-design",
|
||||
title: "Prototype issue-thread interaction cards",
|
||||
description:
|
||||
"Build render-only cards that sit in the issue feed and show suggested tasks before anything is persisted.",
|
||||
priority: "high",
|
||||
assigneeAgentId: "agent-codex",
|
||||
billingCode: "ui-research",
|
||||
labels: ["UI", "interaction"],
|
||||
},
|
||||
{
|
||||
clientKey: "child-stories",
|
||||
parentClientKey: "root-design",
|
||||
title: "Add Storybook coverage for acceptance and rejection states",
|
||||
description:
|
||||
"Cover pending, accepted, rejected, and collapsed-child previews in a fixture-backed story.",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-qa",
|
||||
labels: ["Storybook"],
|
||||
},
|
||||
{
|
||||
clientKey: "child-mixed-thread",
|
||||
parentClientKey: "root-design",
|
||||
title: "Prototype the mixed thread feed",
|
||||
description:
|
||||
"Show comments, activity, live runs, and interaction cards in one chronological feed.",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-codex",
|
||||
labels: ["Issue thread"],
|
||||
},
|
||||
{
|
||||
clientKey: "hidden-follow-up",
|
||||
parentClientKey: "child-mixed-thread",
|
||||
title: "Follow-up polish on spacing and answered summaries",
|
||||
description:
|
||||
"Collapse this under the visible task tree so the preview proves the hidden-descendant treatment.",
|
||||
priority: "low",
|
||||
hiddenInPreview: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAskUserQuestionsInteraction(
|
||||
overrides: Partial<AskUserQuestionsInteraction>,
|
||||
): AskUserQuestionsInteraction {
|
||||
return {
|
||||
id: "interaction-questions-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "ask_user_questions",
|
||||
title: "Resolve open UX decisions before Phase 1",
|
||||
summary:
|
||||
"This form stays local until the operator submits it, so the assignee only wakes once after the whole answer set is ready.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-20T14:18:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:18:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
title: "Before I wire the persistence layer, which preview behavior do you want?",
|
||||
submitLabel: "Send answers",
|
||||
questions: [
|
||||
{
|
||||
id: "collapse-depth",
|
||||
prompt: "How aggressive should the suggested-task preview collapse descendant work?",
|
||||
helpText:
|
||||
"We need enough context to review the tree without making the feed feel like a project plan.",
|
||||
selectionMode: "single",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: "visible-root",
|
||||
label: "Only collapse hidden descendants",
|
||||
description: "Keep top-level and visible child tasks expanded.",
|
||||
},
|
||||
{
|
||||
id: "collapse-all",
|
||||
label: "Collapse all descendants by default",
|
||||
description: "Show only root tasks until the operator expands the tree.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "post-submit-summary",
|
||||
prompt: "What should the answered-state card emphasize after submission?",
|
||||
helpText: "Pick every summary treatment that would help future reviewers.",
|
||||
selectionMode: "multi",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: "answers-inline",
|
||||
label: "Inline answer pills",
|
||||
description: "Keep the exact operator choices visible under each question.",
|
||||
},
|
||||
{
|
||||
id: "summary-note",
|
||||
label: "Short markdown summary",
|
||||
description: "Add a compact narrative summary at the bottom of the card.",
|
||||
},
|
||||
{
|
||||
id: "resolver-meta",
|
||||
label: "Resolver metadata",
|
||||
description: "Show who answered and when without opening the raw thread.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRequestConfirmationInteraction(
|
||||
overrides: Partial<RequestConfirmationInteraction>,
|
||||
): RequestConfirmationInteraction {
|
||||
return {
|
||||
id: "interaction-confirmation-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "request_confirmation",
|
||||
title: "Approve the proposed plan",
|
||||
summary:
|
||||
"The assignee is waiting on a direct board decision before continuing from the plan document.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-20T14:30:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:30:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the plan and let the assignee start implementation?",
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request revisions",
|
||||
rejectRequiresReason: true,
|
||||
rejectReasonLabel: "Describe the plan changes needed before approval",
|
||||
detailsMarkdown:
|
||||
"This confirmation watches the `plan` document revision so stale approvals are blocked if the plan changes.",
|
||||
supersedeOnUserComment: true,
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "11111111-1111-4111-8111-111111111111",
|
||||
revisionNumber: 3,
|
||||
},
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
|
||||
|
||||
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
|
||||
id: "interaction-suggest-accepted",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:16:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:16:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
createdTasks: [
|
||||
{
|
||||
clientKey: "root-design",
|
||||
issueId: "issue-created-1",
|
||||
identifier: "PAP-1713",
|
||||
title: "Prototype issue-thread interaction cards",
|
||||
},
|
||||
{
|
||||
clientKey: "child-stories",
|
||||
issueId: "issue-created-2",
|
||||
identifier: "PAP-1714",
|
||||
title: "Add Storybook coverage for acceptance and rejection states",
|
||||
parentIssueId: "issue-created-1",
|
||||
parentIdentifier: "PAP-1713",
|
||||
},
|
||||
{
|
||||
clientKey: "child-mixed-thread",
|
||||
issueId: "issue-created-3",
|
||||
identifier: "PAP-1715",
|
||||
title: "Prototype the mixed thread feed",
|
||||
parentIssueId: "issue-created-1",
|
||||
parentIdentifier: "PAP-1713",
|
||||
},
|
||||
{
|
||||
clientKey: "hidden-follow-up",
|
||||
issueId: "issue-created-4",
|
||||
identifier: "PAP-1716",
|
||||
title: "Follow-up polish on spacing and answered summaries",
|
||||
parentIssueId: "issue-created-3",
|
||||
parentIdentifier: "PAP-1715",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const rejectedSuggestedTasksInteraction = createSuggestTasksInteraction({
|
||||
id: "interaction-suggest-rejected",
|
||||
status: "rejected",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:17:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:17:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
rejectionReason:
|
||||
"Keep the first pass tighter. The hidden follow-on work is useful, but the acceptance story should stay focused on one visible root and one visible child.",
|
||||
},
|
||||
});
|
||||
|
||||
export const pendingAskUserQuestionsInteraction = createAskUserQuestionsInteraction({});
|
||||
|
||||
export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInteraction({
|
||||
id: "interaction-questions-answered",
|
||||
status: "answered",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:24:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:24:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
answers: [
|
||||
{
|
||||
questionId: "collapse-depth",
|
||||
optionIds: ["visible-root"],
|
||||
},
|
||||
{
|
||||
questionId: "post-submit-summary",
|
||||
optionIds: ["answers-inline", "summary-note", "resolver-meta"],
|
||||
},
|
||||
],
|
||||
summaryMarkdown: [
|
||||
"- Keep visible child tasks expanded when they are part of the main review path.",
|
||||
"- Preserve inline answer chips and resolver metadata in the answered state.",
|
||||
"- Add a short summary note so future reviewers understand the operator's intent without replaying the form.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
export const pendingRequestConfirmationInteraction = createRequestConfirmationInteraction({});
|
||||
|
||||
export const genericPendingRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-generic-pending",
|
||||
title: "Confirm next step",
|
||||
summary: "The assignee needs a lightweight yes or no before continuing.",
|
||||
continuationPolicy: "none",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Continue with the current approach?",
|
||||
},
|
||||
});
|
||||
|
||||
export const optionalDeclineRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-optional-decline",
|
||||
continuationPolicy: "none",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Use the smaller implementation path?",
|
||||
acceptLabel: "Confirm",
|
||||
rejectLabel: "Decline",
|
||||
rejectRequiresReason: false,
|
||||
declineReasonPlaceholder: "Optional: tell the agent what you'd change.",
|
||||
},
|
||||
});
|
||||
|
||||
export const disabledDeclineReasonRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-no-decline-reason",
|
||||
continuationPolicy: "none",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Close this low-risk follow-up as unnecessary?",
|
||||
acceptLabel: "Close it",
|
||||
rejectLabel: "Keep it",
|
||||
allowDeclineReason: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const acceptedRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-accepted",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:34:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:34:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
export const planApprovalAcceptedRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-plan-accepted",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:34:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:34:00.000Z"),
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the plan and let the assignee start implementation?",
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: true,
|
||||
declineReasonPlaceholder: "Optional: what would you like revised?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "11111111-1111-4111-8111-111111111111",
|
||||
revisionNumber: 4,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
export const rejectedRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-rejected",
|
||||
status: "rejected",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:36:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:36:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: "Split the migration and UI work into separate reviewable steps.",
|
||||
},
|
||||
});
|
||||
|
||||
export const rejectedNoReasonRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-rejected-no-reason",
|
||||
status: "rejected",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:37:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:37:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: null,
|
||||
},
|
||||
});
|
||||
|
||||
export const commentExpiredRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-expired-comment",
|
||||
status: "expired",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:38:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:38:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
});
|
||||
|
||||
export const staleTargetRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-expired-target",
|
||||
status: "expired",
|
||||
resolvedByAgentId: "agent-codex",
|
||||
resolvedAt: new Date("2026-04-20T14:40:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:40:00.000Z"),
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the plan and let the assignee start implementation?",
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request revisions",
|
||||
rejectRequiresReason: true,
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "44444444-4444-4444-8444-444444444444",
|
||||
revisionNumber: 4,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "11111111-1111-4111-8111-111111111111",
|
||||
revisionNumber: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const failedRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-failed",
|
||||
status: "failed",
|
||||
updatedAt: new Date("2026-04-20T14:42:00.000Z"),
|
||||
});
|
||||
|
||||
export const issueThreadInteractionComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-thread-board",
|
||||
body: "Pressure-test first-class issue-thread interactions before we touch persistence. I want to see the cards in the real feed, not in a disconnected mock.",
|
||||
createdAt: new Date("2026-04-20T14:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:02:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-thread-agent",
|
||||
authorAgentId: "agent-codex",
|
||||
authorUserId: null,
|
||||
body: "I found the existing issue chat surface and I am adding prototype-only interaction records so the Storybook review can happen before persistence work.",
|
||||
createdAt: new Date("2026-04-20T14:09:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:09:00.000Z"),
|
||||
runId: "run-thread-interaction",
|
||||
runAgentId: "agent-codex",
|
||||
}),
|
||||
];
|
||||
|
||||
export const issueThreadInteractionEvents: IssueTimelineEvent[] = [
|
||||
{
|
||||
id: "event-thread-checkout",
|
||||
createdAt: new Date("2026-04-20T14:01:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
statusChange: {
|
||||
from: "todo",
|
||||
to: "in_progress",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const issueThreadInteractionLiveRuns: LiveRunForIssue[] = [
|
||||
{
|
||||
id: "run-thread-live",
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-20T14:26:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-20T14:26:00.000Z",
|
||||
agentId: "agent-codex",
|
||||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
},
|
||||
];
|
||||
|
||||
export const issueThreadInteractionTranscriptsByRunId = new Map<
|
||||
string,
|
||||
readonly IssueChatTranscriptEntry[]
|
||||
>([
|
||||
[
|
||||
"run-thread-live",
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-20T14:26:02.000Z",
|
||||
text: "Wiring the prototype interaction cards into the same issue feed that already renders comments and live runs.",
|
||||
},
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-20T14:26:04.000Z",
|
||||
text: "Need to keep the payload shapes local to the UI layer so Phase 0 stays non-persistent.",
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
export const mixedIssueThreadInteractions = [
|
||||
acceptedSuggestedTasksInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
];
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
} from "./issue-chat-messages";
|
||||
import type { SuggestTasksInteraction } from "./issue-thread-interactions";
|
||||
import type { IssueTimelineEvent } from "./issue-timeline-events";
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
|
||||
@@ -51,6 +52,39 @@ function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComm
|
||||
};
|
||||
}
|
||||
|
||||
function createInteraction(
|
||||
overrides: Partial<SuggestTasksInteraction> = {},
|
||||
): SuggestTasksInteraction {
|
||||
return {
|
||||
id: "interaction-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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildAssistantPartsFromTranscript", () => {
|
||||
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
@@ -343,6 +377,63 @@ describe("buildIssueChatMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("merges thread interactions into the same chronological feed as comments and runs", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
createComment({
|
||||
id: "comment-1",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
}),
|
||||
],
|
||||
interactions: [
|
||||
createInteraction({
|
||||
id: "interaction-2",
|
||||
createdAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
}),
|
||||
],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [],
|
||||
liveRuns: [
|
||||
{
|
||||
id: "run-live-1",
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:03:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:03:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
},
|
||||
],
|
||||
transcriptsByRunId: new Map([
|
||||
[
|
||||
"run-live-1",
|
||||
[{ kind: "assistant", ts: "2026-04-06T12:03:01.000Z", text: "Working on it." }],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-live-1",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
|
||||
"user:comment-1",
|
||||
"system:interaction:interaction-2",
|
||||
"assistant:run-assistant:run-live-1",
|
||||
]);
|
||||
expect(messages[1]).toMatchObject({
|
||||
metadata: {
|
||||
custom: {
|
||||
kind: "interaction",
|
||||
anchorId: "interaction-interaction-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
|
||||
const messages = buildIssueChatMessages({
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
import type { Agent, IssueComment } from "@paperclipai/shared";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { formatAssigneeUserLabel } from "./assignees";
|
||||
import {
|
||||
buildIssueThreadInteractionSummary,
|
||||
type IssueThreadInteraction,
|
||||
} from "./issue-thread-interactions";
|
||||
import type { IssueTimelineEvent } from "./issue-timeline-events";
|
||||
import {
|
||||
summarizeNotice,
|
||||
@@ -387,6 +391,23 @@ function createTimelineEventMessage(args: {
|
||||
return message;
|
||||
}
|
||||
|
||||
function createInteractionMessage(interaction: IssueThreadInteraction) {
|
||||
const message: ThreadSystemMessage = {
|
||||
id: `interaction:${interaction.id}`,
|
||||
role: "system",
|
||||
createdAt: toDate(interaction.createdAt),
|
||||
content: [{ type: "text", text: buildIssueThreadInteractionSummary(interaction) }],
|
||||
metadata: {
|
||||
custom: {
|
||||
kind: "interaction",
|
||||
anchorId: `interaction-${interaction.id}`,
|
||||
interaction,
|
||||
},
|
||||
},
|
||||
};
|
||||
return message;
|
||||
}
|
||||
|
||||
function runTimestamp(run: IssueChatLinkedRun) {
|
||||
return run.finishedAt ?? run.startedAt ?? run.createdAt;
|
||||
}
|
||||
@@ -738,6 +759,7 @@ function createLiveRunMessage(args: {
|
||||
|
||||
export function buildIssueChatMessages(args: {
|
||||
comments: readonly IssueChatComment[];
|
||||
interactions?: readonly IssueThreadInteraction[];
|
||||
timelineEvents: readonly IssueTimelineEvent[];
|
||||
linkedRuns: readonly IssueChatLinkedRun[];
|
||||
liveRuns: readonly LiveRunForIssue[];
|
||||
@@ -754,6 +776,7 @@ export function buildIssueChatMessages(args: {
|
||||
}) {
|
||||
const {
|
||||
comments,
|
||||
interactions = [],
|
||||
timelineEvents,
|
||||
linkedRuns,
|
||||
liveRuns,
|
||||
@@ -779,6 +802,14 @@ export function buildIssueChatMessages(args: {
|
||||
});
|
||||
}
|
||||
|
||||
for (const interaction of sortByCreated(interactions)) {
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(interaction.createdAt),
|
||||
order: 2,
|
||||
message: createInteractionMessage(interaction),
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of sortByCreated(timelineEvents)) {
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(event.createdAt),
|
||||
|
||||
150
ui/src/lib/issue-thread-interactions.test.ts
Normal file
150
ui/src/lib/issue-thread-interactions.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIssueThreadInteractionSummary,
|
||||
buildSuggestedTaskTree,
|
||||
collectSuggestedTaskClientKeys,
|
||||
countSuggestedTaskNodes,
|
||||
getQuestionAnswerLabels,
|
||||
} from "./issue-thread-interactions";
|
||||
|
||||
describe("buildSuggestedTaskTree", () => {
|
||||
it("preserves parent-child relationships from client keys", () => {
|
||||
const roots = buildSuggestedTaskTree([
|
||||
{
|
||||
clientKey: "root",
|
||||
title: "Root",
|
||||
},
|
||||
{
|
||||
clientKey: "child",
|
||||
parentClientKey: "root",
|
||||
title: "Child",
|
||||
},
|
||||
{
|
||||
clientKey: "grandchild",
|
||||
parentClientKey: "child",
|
||||
title: "Grandchild",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(roots).toHaveLength(1);
|
||||
expect(roots[0]?.task.clientKey).toBe("root");
|
||||
expect(roots[0]?.children[0]?.task.clientKey).toBe("child");
|
||||
expect(countSuggestedTaskNodes(roots[0]!)).toBe(3);
|
||||
expect(collectSuggestedTaskClientKeys(roots[0]!)).toEqual(["root", "child", "grandchild"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue thread interaction helpers", () => {
|
||||
it("summarizes task and question interactions", () => {
|
||||
expect(buildIssueThreadInteractionSummary({
|
||||
id: "interaction-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "suggest_tasks",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
updatedAt: "2026-04-06T12:00:00.000Z",
|
||||
payload: {
|
||||
version: 1,
|
||||
tasks: [
|
||||
{ clientKey: "task-1", title: "One" },
|
||||
{ clientKey: "task-2", title: "Two" },
|
||||
],
|
||||
},
|
||||
})).toBe("Suggested 2 tasks");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary({
|
||||
id: "interaction-accepted",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "suggest_tasks",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
updatedAt: "2026-04-06T12:00:00.000Z",
|
||||
payload: {
|
||||
version: 1,
|
||||
tasks: [
|
||||
{ clientKey: "task-1", title: "One" },
|
||||
{ clientKey: "task-2", title: "Two" },
|
||||
],
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
createdTasks: [{ clientKey: "task-1", issueId: "child-1" }],
|
||||
skippedClientKeys: ["task-2"],
|
||||
},
|
||||
})).toBe("Accepted 1 of 2 tasks");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary({
|
||||
id: "interaction-2",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
updatedAt: "2026-04-06T12:00:00.000Z",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [
|
||||
{
|
||||
id: "question-1",
|
||||
prompt: "Pick one",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "option-1", label: "Option 1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})).toBe("Asked 1 question");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary({
|
||||
id: "interaction-answered",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "ask_user_questions",
|
||||
status: "answered",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
updatedAt: "2026-04-06T12:00:00.000Z",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [
|
||||
{
|
||||
id: "question-1",
|
||||
prompt: "Pick one",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "option-1", label: "Option 1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
answers: [{ questionId: "question-1", optionIds: ["option-1"] }],
|
||||
},
|
||||
})).toBe("Answered 1 question");
|
||||
});
|
||||
|
||||
it("maps stored option ids back to labels for answered summaries", () => {
|
||||
const labels = getQuestionAnswerLabels({
|
||||
question: {
|
||||
id: "question-1",
|
||||
prompt: "Pick options",
|
||||
selectionMode: "multi",
|
||||
options: [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
answers: [
|
||||
{
|
||||
questionId: "question-1",
|
||||
optionIds: ["option-2", "option-1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(labels).toEqual(["Option 2", "Option 1"]);
|
||||
});
|
||||
});
|
||||
140
ui/src/lib/issue-thread-interactions.ts
Normal file
140
ui/src/lib/issue-thread-interactions.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
export type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
AskUserQuestionsPayload,
|
||||
AskUserQuestionsQuestion,
|
||||
AskUserQuestionsQuestionOption,
|
||||
AskUserQuestionsResult,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionActorFields,
|
||||
IssueThreadInteractionBase,
|
||||
IssueThreadInteractionContinuationPolicy,
|
||||
IssueThreadInteractionStatus,
|
||||
RequestConfirmationInteraction,
|
||||
RequestConfirmationIssueDocumentTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
RequestConfirmationTarget,
|
||||
SuggestedTaskDraft,
|
||||
SuggestTasksInteraction,
|
||||
SuggestTasksPayload,
|
||||
SuggestTasksResult,
|
||||
SuggestTasksResultCreatedTask,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
AskUserQuestionsQuestion,
|
||||
IssueThreadInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestedTaskDraft,
|
||||
SuggestTasksInteraction,
|
||||
SuggestTasksResultCreatedTask,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export interface SuggestedTaskTreeNode {
|
||||
task: SuggestedTaskDraft;
|
||||
children: SuggestedTaskTreeNode[];
|
||||
}
|
||||
|
||||
export function isIssueThreadInteraction(
|
||||
value: unknown,
|
||||
): value is IssueThreadInteraction {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<IssueThreadInteraction>;
|
||||
return typeof candidate.id === "string"
|
||||
&& typeof candidate.companyId === "string"
|
||||
&& typeof candidate.issueId === "string"
|
||||
&& (
|
||||
candidate.kind === "suggest_tasks"
|
||||
|| candidate.kind === "ask_user_questions"
|
||||
|| candidate.kind === "request_confirmation"
|
||||
);
|
||||
}
|
||||
|
||||
export function buildIssueThreadInteractionSummary(
|
||||
interaction: IssueThreadInteraction,
|
||||
) {
|
||||
if (interaction.kind === "suggest_tasks") {
|
||||
const count = interaction.payload.tasks.length;
|
||||
if (interaction.status === "accepted") {
|
||||
const createdCount = interaction.result?.createdTasks?.length ?? 0;
|
||||
const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0;
|
||||
if (skippedCount > 0) {
|
||||
return `Accepted ${createdCount} of ${count} tasks`;
|
||||
}
|
||||
return createdCount === 1 ? "Accepted 1 task" : `Accepted ${createdCount} tasks`;
|
||||
}
|
||||
if (interaction.status === "rejected") {
|
||||
return count === 1 ? "Rejected 1 task" : `Rejected ${count} tasks`;
|
||||
}
|
||||
return count === 1 ? "Suggested 1 task" : `Suggested ${count} tasks`;
|
||||
}
|
||||
|
||||
if (interaction.kind === "request_confirmation") {
|
||||
if (interaction.status === "accepted") return "Confirmed request";
|
||||
if (interaction.status === "rejected") return "Declined request";
|
||||
if (interaction.status === "expired") {
|
||||
const outcome = interaction.result?.outcome;
|
||||
if (outcome === "superseded_by_comment") return "Confirmation expired after comment";
|
||||
if (outcome === "stale_target") return "Confirmation expired after target changed";
|
||||
return "Confirmation expired";
|
||||
}
|
||||
return "Requested confirmation";
|
||||
}
|
||||
|
||||
const count = interaction.payload.questions.length;
|
||||
if (interaction.status === "answered") {
|
||||
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
|
||||
}
|
||||
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
|
||||
}
|
||||
|
||||
export function buildSuggestedTaskTree(
|
||||
tasks: readonly SuggestedTaskDraft[],
|
||||
): SuggestedTaskTreeNode[] {
|
||||
const nodes = new Map<string, SuggestedTaskTreeNode>();
|
||||
for (const task of tasks) {
|
||||
nodes.set(task.clientKey, { task, children: [] });
|
||||
}
|
||||
|
||||
const roots: SuggestedTaskTreeNode[] = [];
|
||||
for (const task of tasks) {
|
||||
const node = nodes.get(task.clientKey);
|
||||
if (!node) continue;
|
||||
const parentNode = task.parentClientKey ? nodes.get(task.parentClientKey) : null;
|
||||
if (parentNode) {
|
||||
parentNode.children.push(node);
|
||||
continue;
|
||||
}
|
||||
roots.push(node);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function countSuggestedTaskNodes(node: SuggestedTaskTreeNode): number {
|
||||
return 1 + node.children.reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0);
|
||||
}
|
||||
|
||||
export function collectSuggestedTaskClientKeys(node: SuggestedTaskTreeNode): string[] {
|
||||
return [
|
||||
node.task.clientKey,
|
||||
...node.children.flatMap((child) => collectSuggestedTaskClientKeys(child)),
|
||||
];
|
||||
}
|
||||
|
||||
export function getQuestionAnswerLabels(args: {
|
||||
question: AskUserQuestionsQuestion;
|
||||
answers: readonly AskUserQuestionsAnswer[];
|
||||
}) {
|
||||
const { question, answers } = args;
|
||||
const selectedIds =
|
||||
answers.find((answer) => answer.questionId === question.id)?.optionIds ?? [];
|
||||
const optionLabelById = new Map(
|
||||
question.options.map((option) => [option.id, option.label] as const),
|
||||
);
|
||||
return selectedIds
|
||||
.map((optionId) => optionLabelById.get(optionId))
|
||||
.filter((label): label is string => typeof label === "string");
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export const queryKeys = {
|
||||
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
interactions: (issueId: string) => ["issues", "interactions", issueId] as const,
|
||||
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
|
||||
@@ -112,15 +112,20 @@ import {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
type AskUserQuestionsAnswer,
|
||||
type ActivityEvent,
|
||||
type Agent,
|
||||
type FeedbackVote,
|
||||
type Issue,
|
||||
type IssueAttachment,
|
||||
type IssueComment,
|
||||
type IssueThreadInteraction,
|
||||
type RequestConfirmationInteraction,
|
||||
type SuggestTasksInteraction,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
@@ -509,6 +514,7 @@ type IssueDetailChatTabProps = {
|
||||
blockedBy: Issue["blockedBy"];
|
||||
comments: IssueDetailComment[];
|
||||
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
||||
interactions: IssueThreadInteraction[];
|
||||
hasOlderComments: boolean;
|
||||
commentsLoadingOlder: boolean;
|
||||
onLoadOlderComments: () => void;
|
||||
@@ -538,6 +544,15 @@ type IssueDetailChatTabProps = {
|
||||
onCancelQueued: (commentId: string) => void;
|
||||
interruptingQueuedRunId: string | null;
|
||||
onImageClick: (src: string) => void;
|
||||
onAcceptInteraction: (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
) => Promise<void>;
|
||||
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
|
||||
onSubmitInteractionAnswers: (
|
||||
interaction: IssueThreadInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
@@ -549,6 +564,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
blockedBy,
|
||||
comments,
|
||||
locallyQueuedCommentRunIds,
|
||||
interactions,
|
||||
hasOlderComments,
|
||||
commentsLoadingOlder,
|
||||
onLoadOlderComments,
|
||||
@@ -574,6 +590,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
}: IssueDetailChatTabProps) {
|
||||
const { data: activity } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
@@ -704,6 +723,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
<IssueChatThread
|
||||
composerRef={composerRef}
|
||||
comments={commentsWithRunMeta}
|
||||
interactions={interactions}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
@@ -735,6 +755,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
stoppingRunId={interruptingQueuedRunId}
|
||||
onStopRun={onInterruptQueued}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={(interaction, answers) =>
|
||||
onSubmitInteractionAnswers(interaction, answers)
|
||||
}
|
||||
onCancelRun={runningIssueRun
|
||||
? async () => {
|
||||
await onInterruptQueued(runningIssueRun.id);
|
||||
@@ -1006,6 +1031,12 @@ export function IssueDetail() {
|
||||
() => flattenIssueCommentPages(commentPages?.pages),
|
||||
[commentPages?.pages],
|
||||
);
|
||||
const { data: interactions = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.interactions(issueId!),
|
||||
queryFn: () => issuesApi.listInteractions(issueId!),
|
||||
enabled: !!issueId,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<IssueThreadInteraction[]>(issueId ?? "pending"),
|
||||
});
|
||||
|
||||
const { data: attachments, isLoading: attachmentsLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.attachments(issueId!),
|
||||
@@ -1207,10 +1238,12 @@ export function IssueDetail() {
|
||||
const invalidateIssueDetail = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!) });
|
||||
}, [issueId, queryClient]);
|
||||
const invalidateIssueThreadLazily = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!), refetchType: "inactive" });
|
||||
}, [issueId, queryClient]);
|
||||
|
||||
const invalidateIssueRunState = useCallback(() => {
|
||||
@@ -1245,6 +1278,22 @@ export function IssueDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
}
|
||||
}, [queryClient, selectedCompanyId]);
|
||||
const upsertInteractionInCache = useCallback((interaction: IssueThreadInteraction) => {
|
||||
queryClient.setQueryData<IssueThreadInteraction[] | undefined>(
|
||||
queryKeys.issues.interactions(issueId!),
|
||||
(current) => {
|
||||
const existing = current ?? [];
|
||||
const next = existing.filter((entry) => entry.id !== interaction.id);
|
||||
next.push(interaction);
|
||||
next.sort((left, right) => {
|
||||
const createdAtDelta =
|
||||
new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
|
||||
return createdAtDelta === 0 ? left.id.localeCompare(right.id) : createdAtDelta;
|
||||
});
|
||||
return next;
|
||||
},
|
||||
);
|
||||
}, [issueId, queryClient]);
|
||||
|
||||
const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable<string>, data: Record<string, unknown>) => {
|
||||
queryClient.setQueriesData<Issue>(
|
||||
@@ -1495,6 +1544,89 @@ export function IssueDetail() {
|
||||
}
|
||||
},
|
||||
});
|
||||
const acceptInteraction = useMutation({
|
||||
mutationFn: ({
|
||||
interaction,
|
||||
selectedClientKeys,
|
||||
}: {
|
||||
interaction: ActionableIssueThreadInteraction;
|
||||
selectedClientKeys?: string[];
|
||||
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
|
||||
onSuccess: (interaction) => {
|
||||
upsertInteractionInCache(interaction);
|
||||
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(resolvedCompanyId, issue.id) });
|
||||
}
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueCollections();
|
||||
const createdCount = interaction.kind === "suggest_tasks"
|
||||
? interaction.result?.createdTasks?.length ?? 0
|
||||
: 0;
|
||||
const skippedCount = interaction.kind === "suggest_tasks"
|
||||
? interaction.result?.skippedClientKeys?.length ?? 0
|
||||
: 0;
|
||||
pushToast({
|
||||
title: interaction.kind === "request_confirmation"
|
||||
? "Request confirmed"
|
||||
: skippedCount > 0
|
||||
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
|
||||
: "Suggested tasks accepted",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Accept failed",
|
||||
body: err instanceof Error ? err.message : "Unable to accept the suggested tasks",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const rejectInteraction = useMutation({
|
||||
mutationFn: ({ interaction, reason }: { interaction: ActionableIssueThreadInteraction; reason?: string }) =>
|
||||
issuesApi.rejectInteraction(issueId!, interaction.id, reason),
|
||||
onSuccess: (interaction) => {
|
||||
upsertInteractionInCache(interaction);
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueCollections();
|
||||
pushToast({
|
||||
title: interaction.kind === "request_confirmation" ? "Request declined" : "Suggestion rejected",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Reject failed",
|
||||
body: err instanceof Error ? err.message : "Unable to reject the suggested tasks",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const answerInteraction = useMutation({
|
||||
mutationFn: ({
|
||||
interaction,
|
||||
answers,
|
||||
}: {
|
||||
interaction: IssueThreadInteraction;
|
||||
answers: AskUserQuestionsAnswer[];
|
||||
}) => issuesApi.respondToInteraction(issueId!, interaction.id, { answers }),
|
||||
onSuccess: (interaction) => {
|
||||
upsertInteractionInCache(interaction);
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueCollections();
|
||||
pushToast({
|
||||
title: "Answers submitted",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Submit failed",
|
||||
body: err instanceof Error ? err.message : "Unable to submit answers",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const addCommentAndReassign = useMutation({
|
||||
mutationFn: ({
|
||||
@@ -2224,6 +2356,21 @@ export function IssueDetail() {
|
||||
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
}, [interruptQueuedComment]);
|
||||
const handleAcceptInteraction = useCallback(async (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
) => {
|
||||
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
|
||||
}, [acceptInteraction]);
|
||||
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
|
||||
await rejectInteraction.mutateAsync({ interaction, reason });
|
||||
}, [rejectInteraction]);
|
||||
const handleSubmitInteractionAnswers = useCallback(async (
|
||||
interaction: IssueThreadInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => {
|
||||
await answerInteraction.mutateAsync({ interaction, answers });
|
||||
}, [answerInteraction]);
|
||||
|
||||
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
@@ -2775,6 +2922,7 @@ export function IssueDetail() {
|
||||
blockedBy={issue.blockedBy ?? []}
|
||||
comments={threadComments}
|
||||
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
|
||||
interactions={interactions}
|
||||
hasOlderComments={hasOlderComments}
|
||||
commentsLoadingOlder={commentsLoadingOlder}
|
||||
onLoadOlderComments={loadOlderComments}
|
||||
@@ -2800,6 +2948,9 @@ export function IssueDetail() {
|
||||
onCancelQueued={handleCancelQueuedComment}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||
onImageClick={handleChatImageClick}
|
||||
onAcceptInteraction={handleAcceptInteraction}
|
||||
onRejectInteraction={handleRejectInteraction}
|
||||
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
681
ui/storybook/stories/issue-thread-interactions.stories.tsx
Normal file
681
ui/storybook/stories/issue-thread-interactions.stories.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
acceptedSuggestedTasksInteraction,
|
||||
answeredAskUserQuestionsInteraction,
|
||||
acceptedRequestConfirmationInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
genericPendingRequestConfirmationInteraction,
|
||||
issueThreadInteractionComments,
|
||||
issueThreadInteractionEvents,
|
||||
issueThreadInteractionFixtureMeta,
|
||||
issueThreadInteractionLiveRuns,
|
||||
issueThreadInteractionTranscriptsByRunId,
|
||||
mixedIssueThreadInteractions,
|
||||
optionalDeclineRequestConfirmationInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
planApprovalAcceptedRequestConfirmationInteraction,
|
||||
rejectedNoReasonRequestConfirmationInteraction,
|
||||
rejectedRequestConfirmationInteraction,
|
||||
rejectedSuggestedTasksInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "@/lib/issue-thread-interactions";
|
||||
import { storybookAgentMap } from "../fixtures/paperclipData";
|
||||
|
||||
const boardUserLabels = new Map<string, string>([
|
||||
[issueThreadInteractionFixtureMeta.currentUserId, "Riley Board"],
|
||||
["user-product", "Mara Product"],
|
||||
]);
|
||||
|
||||
function StoryFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner space-y-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<div className="paperclip-story__label">{eyebrow}</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveSuggestedTasksCard() {
|
||||
const [interaction, setInteraction] = useState<SuggestTasksInteraction>(
|
||||
pendingSuggestedTasksInteraction,
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={(_interaction, selectedClientKeys) =>
|
||||
setInteraction({
|
||||
...acceptedSuggestedTasksInteraction,
|
||||
result: {
|
||||
version: 1,
|
||||
createdTasks: (acceptedSuggestedTasksInteraction.result?.createdTasks ?? []).filter((task) =>
|
||||
selectedClientKeys?.includes(task.clientKey) ?? true),
|
||||
skippedClientKeys: pendingSuggestedTasksInteraction.payload.tasks
|
||||
.map((task) => task.clientKey)
|
||||
.filter((clientKey) => !(selectedClientKeys?.includes(clientKey) ?? true)),
|
||||
},
|
||||
})}
|
||||
onRejectInteraction={(_interaction, reason) =>
|
||||
setInteraction({
|
||||
...rejectedSuggestedTasksInteraction,
|
||||
result: {
|
||||
version: 1,
|
||||
...(rejectedSuggestedTasksInteraction.result ?? {}),
|
||||
rejectionReason:
|
||||
reason
|
||||
|| rejectedSuggestedTasksInteraction.result?.rejectionReason
|
||||
|| null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnsweredInteraction(
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
): AskUserQuestionsInteraction {
|
||||
const labels = pendingAskUserQuestionsInteraction.payload.questions.flatMap((question) => {
|
||||
const answer = answers.find((entry) => entry.questionId === question.id);
|
||||
if (!answer) return [];
|
||||
return question.options
|
||||
.filter((option) => answer.optionIds.includes(option.id))
|
||||
.map((option) => option.label);
|
||||
});
|
||||
|
||||
return {
|
||||
...answeredAskUserQuestionsInteraction,
|
||||
result: {
|
||||
version: 1,
|
||||
answers,
|
||||
summaryMarkdown: labels.map((label) => `- ${label}`).join("\n"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function InteractiveAskUserQuestionsCard() {
|
||||
const [interaction, setInteraction] = useState<AskUserQuestionsInteraction>(
|
||||
pendingAskUserQuestionsInteraction,
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onSubmitInteractionAnswers={(_interaction, answers) =>
|
||||
setInteraction(buildAnsweredInteraction(answers))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveRequestConfirmationCard() {
|
||||
const [interaction, setInteraction] = useState<RequestConfirmationInteraction>(
|
||||
pendingRequestConfirmationInteraction,
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={() => setInteraction(acceptedRequestConfirmationInteraction)}
|
||||
onRejectInteraction={(_interaction, reason) =>
|
||||
setInteraction({
|
||||
...rejectedRequestConfirmationInteraction,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: reason || rejectedRequestConfirmationInteraction.result?.reason || null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoOpenDeclineRequestConfirmationCard({
|
||||
interaction,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const declineButton = Array.from(ref.current?.querySelectorAll("button") ?? [])
|
||||
.find((button) => button.textContent?.includes(interaction.payload.rejectLabel ?? "Decline"));
|
||||
declineButton?.click();
|
||||
}, [interaction]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={() => undefined}
|
||||
onRejectInteraction={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Chat & Comments/Issue Thread Interactions",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Interaction cards for `suggest_tasks`, `ask_user_questions`, and `request_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SuggestedTasksPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending suggested tasks"
|
||||
description="Draft issues are selectable before they become real issues."
|
||||
>
|
||||
<InteractiveSuggestedTasksCard />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const SuggestedTasksAccepted: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Accepted suggested tasks"
|
||||
description="Created issues are linked back to their original draft rows."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedSuggestedTasksInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const SuggestedTasksRejected: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Rejected suggested tasks"
|
||||
description="The declined draft stays visible with its rejection note."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={rejectedSuggestedTasksInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const AskUserQuestionsPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending question form"
|
||||
description="Single- and multi-select questions remain local until submitted."
|
||||
>
|
||||
<InteractiveAskUserQuestionsCard />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const AskUserQuestionsAnswered: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Answered question form"
|
||||
description="Selected answers and the submitted summary remain attached to the thread."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={answeredAskUserQuestionsInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending request confirmation"
|
||||
description="A generic confirmation can render without a target or custom labels."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={genericPendingRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={() => undefined}
|
||||
onRejectInteraction={() => undefined}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationPendingWithTarget: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending request confirmation with target"
|
||||
description="The watched plan document renders as a compact target chip."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={pendingRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={() => undefined}
|
||||
onRejectInteraction={() => undefined}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationPendingDecliningOptional: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending optional decline"
|
||||
description="The decline textarea is visible, but a reason is optional."
|
||||
>
|
||||
<AutoOpenDeclineRequestConfirmationCard
|
||||
interaction={optionalDeclineRequestConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationPendingRequireReason: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending required decline reason"
|
||||
description="A plan approval waits for an explicit board decision and requires a decline reason."
|
||||
>
|
||||
<InteractiveRequestConfirmationCard />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationConfirmed: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Confirmed request confirmation"
|
||||
description="The resolved state remains visible without active controls."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationDeclinedWithReason: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Declined request confirmation"
|
||||
description="The decline reason stays attached to the request in the thread."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={rejectedRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationDeclinedNoReason: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Declined without a reason"
|
||||
description="The card stays compact when no decline reason was provided."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={rejectedNoReasonRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationExpiredByComment: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Expired by comment"
|
||||
description="A board comment superseded the request before resolution."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={commentExpiredRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationExpiredByTargetChange: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Expired by target change"
|
||||
description="The watched plan document moved to a newer revision before approval."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={staleTargetRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationPlanApprovalPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending plan approval"
|
||||
description="The plan-approval variant keeps the approval labels and target chip visible."
|
||||
>
|
||||
<InteractiveRequestConfirmationCard />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationPlanApprovalConfirmed: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Confirmed plan approval"
|
||||
description="The resolved plan approval reads as a compact receipt."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={planApprovalAcceptedRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationFailed: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Failed request confirmation"
|
||||
description="The failed state provides explicit recovery copy."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={failedRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
|
||||
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
|
||||
|
||||
export const ReviewSurface: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<section className="paperclip-story__frame p-6">
|
||||
<div className="paperclip-story__label">Thread interactions</div>
|
||||
<div className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
This review surface pressure-tests the thread interaction kinds directly inside the issue
|
||||
chat surface. The card language leans closer to
|
||||
annotated review sheets than generic admin widgets so the objects feel like first-class work
|
||||
artifacts in the thread.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Section eyebrow="Suggested Tasks" title="Pending, accepted, and rejected task-tree cards">
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<ScenarioCard
|
||||
title="Pending"
|
||||
description="The draft tree stays editable and non-persistent until someone accepts or rejects it."
|
||||
>
|
||||
<InteractiveSuggestedTasksCard />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Accepted"
|
||||
description="Accepted state resolves to created issue links while keeping the original suggestion visible in-thread."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedSuggestedTasksInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Rejected"
|
||||
description="The rejection reason remains attached to the artifact so future reviewers can see why the draft was declined."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={rejectedSuggestedTasksInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Ask User Questions" title="Pending multi-question form and answered summary">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard
|
||||
title="Pending"
|
||||
description="Answers stay local across the whole form and only wake the assignee once after final submit."
|
||||
>
|
||||
<InteractiveAskUserQuestionsCard />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Answered"
|
||||
description="The answered state keeps the exact choices visible and adds a compact summary note for later review."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={answeredAskUserQuestionsInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Request Confirmation" title="Plan approval and compact resolution states">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard
|
||||
title="Plan approval"
|
||||
description="The pending card links to the watched plan revision and requires a reason when declined."
|
||||
>
|
||||
<InteractiveRequestConfirmationCard />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Accepted"
|
||||
description="Accepted confirmations stay visible as resolved work artifacts."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Rejected"
|
||||
description="Rejected confirmations keep the board's decline reason attached."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={rejectedRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Expired states"
|
||||
description="Comment and target-change expiry states are compact and disabled."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<IssueThreadInteractionCard
|
||||
interaction={commentExpiredRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={staleTargetRequestConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</div>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Mixed Feed" title="Interaction cards in the real issue thread">
|
||||
<ScenarioCard
|
||||
title="IssueChatThread composition"
|
||||
description="Comments, timeline events, accepted task suggestions, a pending confirmation, a pending question form, and an active run share the same feed."
|
||||
>
|
||||
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(14,165,233,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_42%),var(--background)] p-5 shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
|
||||
<IssueChatThread
|
||||
comments={issueThreadInteractionComments}
|
||||
interactions={mixedIssueThreadInteractions}
|
||||
timelineEvents={issueThreadInteractionEvents}
|
||||
liveRuns={issueThreadInteractionLiveRuns}
|
||||
transcriptsByRunId={issueThreadInteractionTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => runId === "run-thread-live"}
|
||||
companyId={issueThreadInteractionFixtureMeta.companyId}
|
||||
projectId={issueThreadInteractionFixtureMeta.projectId}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
agentMap={storybookAgentMap}
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
/>
|
||||
</div>
|
||||
</ScenarioCard>
|
||||
</Section>
|
||||
</StoryFrame>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Covers the prototype states called out in [PAP-1709](/PAP/issues/PAP-1709): suggested-task previews, collapsed descendants, rejection reasons, request confirmations, multi-question answers, and a mixed issue thread.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user