From b23ac4fd79b30117f062841a678facfac94931b2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 20 Apr 2026 17:26:12 -0500 Subject: [PATCH] Persist issue thread interactions and board actions --- .../0063_issue_thread_interactions.sql | 65 +++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + .../src/schema/issue_thread_interactions.ts | 49 ++ packages/shared/src/constants.ts | 23 + packages/shared/src/index.ts | 42 ++ packages/shared/src/types/index.ts | 16 + packages/shared/src/types/issue.ts | 120 +++++ packages/shared/src/validators/index.ts | 20 + packages/shared/src/validators/issue.ts | 161 +++++++ .../issue-thread-interaction-routes.test.ts | 338 +++++++++++++ .../issue-thread-interactions-service.test.ts | 249 ++++++++++ server/src/routes/issues.ts | 263 +++++++++- server/src/services/index.ts | 1 + .../issue-thread-interactions.test.ts | 156 ++++++ .../src/services/issue-thread-interactions.ts | 449 ++++++++++++++++++ ui/src/api/issues.ts | 17 + ui/src/components/IssueChatThread.test.tsx | 170 +++++++ ui/src/context/LiveUpdatesProvider.tsx | 3 + ui/src/lib/issue-thread-interactions.ts | 144 ++---- ui/src/lib/queryKeys.ts | 1 + ui/src/pages/IssueDetail.tsx | 128 +++++ 22 files changed, 2305 insertions(+), 118 deletions(-) create mode 100644 packages/db/src/migrations/0063_issue_thread_interactions.sql create mode 100644 packages/db/src/schema/issue_thread_interactions.ts create mode 100644 server/src/__tests__/issue-thread-interaction-routes.test.ts create mode 100644 server/src/__tests__/issue-thread-interactions-service.test.ts create mode 100644 server/src/services/issue-thread-interactions.test.ts create mode 100644 server/src/services/issue-thread-interactions.ts diff --git a/packages/db/src/migrations/0063_issue_thread_interactions.sql b/packages/db/src/migrations/0063_issue_thread_interactions.sql new file mode 100644 index 00000000000..121b0975271 --- /dev/null +++ b/packages/db/src/migrations/0063_issue_thread_interactions.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS "issue_thread_interactions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "kind" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "continuation_policy" text DEFAULT 'wake_assignee' NOT NULL, + "source_comment_id" uuid, + "source_run_id" uuid, + "title" text, + "summary" text, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "resolved_by_agent_id" uuid, + "resolved_by_user_id" text, + "payload" jsonb NOT NULL, + "result" jsonb, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_company_id_companies_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_comment_id_issue_comments_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_comment_id_issue_comments_id_fk" FOREIGN KEY ("source_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_resolved_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_issue_idx" ON "issue_thread_interactions" USING btree ("issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_created_at_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_status_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_source_comment_idx" ON "issue_thread_interactions" USING btree ("source_comment_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 9a1a4700230..1051980e72d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1776780000000, "tag": "0062_routine_run_dispatch_fingerprint", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1776726000000, + "tag": "0063_issue_thread_interactions", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 75dccf1b6d7..d1914344480 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -35,6 +35,7 @@ export { labels } from "./labels.js"; export { issueLabels } from "./issue_labels.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; +export { issueThreadInteractions } from "./issue_thread_interactions.js"; export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueInboxArchives } from "./issue_inbox_archives.js"; export { inboxDismissals } from "./inbox_dismissals.js"; diff --git a/packages/db/src/schema/issue_thread_interactions.ts b/packages/db/src/schema/issue_thread_interactions.ts new file mode 100644 index 00000000000..bd71e796311 --- /dev/null +++ b/packages/db/src/schema/issue_thread_interactions.ts @@ -0,0 +1,49 @@ +import type { + IssueThreadInteractionPayload, + IssueThreadInteractionResult, +} from "@paperclipai/shared"; +import { pgTable, uuid, text, timestamp, jsonb, index } 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"), + sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }), + sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + title: text("title"), + summary: text("summary"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id), + createdByUserId: text("created_by_user_id"), + resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id), + resolvedByUserId: text("resolved_by_user_id"), + payload: jsonb("payload").$type().notNull(), + result: jsonb("result").$type(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + issueIdx: index("issue_thread_interactions_issue_idx").on(table.issueId), + companyIssueCreatedAtIdx: index("issue_thread_interactions_company_issue_created_at_idx").on( + table.companyId, + table.issueId, + table.createdAt, + ), + companyIssueStatusIdx: index("issue_thread_interactions_company_issue_status_idx").on( + table.companyId, + table.issueId, + table.status, + ), + sourceCommentIdx: index("issue_thread_interactions_source_comment_idx").on(table.sourceCommentId), + }), +); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0a5ee82ebbd..763bf69722a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -137,6 +137,29 @@ 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", +] 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", +] 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}`; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5b1a291f197..ecabee86702 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,9 @@ export { INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, + ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_STATUSES, + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_ORIGIN_KINDS, ISSUE_RELATION_TYPES, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, @@ -105,6 +108,9 @@ export { type AgentIconName, type IssueStatus, type IssuePriority, + type IssueThreadInteractionKind, + type IssueThreadInteractionStatus, + type IssueThreadInteractionContinuationPolicy, type BuiltInIssueOriginKind, type PluginIssueOriginKind, type IssueOriginKind, @@ -300,6 +306,22 @@ export type { IssueExecutionStagePrincipal, IssueExecutionDecision, IssueComment, + IssueThreadInteractionActorFields, + SuggestedTaskDraft, + SuggestTasksPayload, + SuggestTasksResultCreatedTask, + SuggestTasksResult, + AskUserQuestionsQuestionOption, + AskUserQuestionsQuestion, + AskUserQuestionsPayload, + AskUserQuestionsAnswer, + AskUserQuestionsResult, + IssueThreadInteractionBase, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + IssueThreadInteraction, + IssueThreadInteractionPayload, + IssueThreadInteractionResult, IssueDocument, IssueDocumentSummary, DocumentRevision, @@ -555,6 +577,22 @@ export { issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, + issueThreadInteractionStatusSchema, + issueThreadInteractionKindSchema, + issueThreadInteractionContinuationPolicySchema, + suggestedTaskDraftSchema, + suggestTasksPayloadSchema, + suggestTasksResultCreatedTaskSchema, + suggestTasksResultSchema, + askUserQuestionsQuestionOptionSchema, + askUserQuestionsQuestionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsAnswerSchema, + askUserQuestionsResultSchema, + createIssueThreadInteractionSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, @@ -580,6 +618,10 @@ export { type UpdateIssue, type CheckoutIssue, type AddIssueComment, + type CreateIssueThreadInteraction, + type AcceptIssueThreadInteraction, + type RejectIssueThreadInteraction, + type RespondIssueThreadInteraction, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type CreateIssueWorkProduct, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 00c9d85d740..a9d674a17d6 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -114,6 +114,22 @@ export type { IssueExecutionStagePrincipal, IssueExecutionDecision, IssueComment, + IssueThreadInteractionActorFields, + SuggestedTaskDraft, + SuggestTasksPayload, + SuggestTasksResultCreatedTask, + SuggestTasksResult, + AskUserQuestionsQuestionOption, + AskUserQuestionsQuestion, + AskUserQuestionsPayload, + AskUserQuestionsAnswer, + AskUserQuestionsResult, + IssueThreadInteractionBase, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + IssueThreadInteraction, + IssueThreadInteractionPayload, + IssueThreadInteractionResult, IssueDocument, IssueDocumentSummary, DocumentRevision, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 3307b534159..72bff60505d 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -6,6 +6,9 @@ import type { IssueExecutionStateStatus, IssueOriginKind, IssuePriority, + IssueThreadInteractionContinuationPolicy, + IssueThreadInteractionKind, + IssueThreadInteractionStatus, IssueStatus, } from "../constants.js"; import type { Goal } from "./goal.js"; @@ -263,6 +266,123 @@ 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[]; + 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 IssueThreadInteractionBase extends IssueThreadInteractionActorFields { + id: string; + companyId: string; + issueId: string; + kind: IssueThreadInteractionKind; + 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 type IssueThreadInteraction = + | SuggestTasksInteraction + | AskUserQuestionsInteraction; + +export type IssueThreadInteractionPayload = + | SuggestTasksPayload + | AskUserQuestionsPayload; + +export type IssueThreadInteractionResult = + | SuggestTasksResult + | AskUserQuestionsResult; + export interface IssueAttachment { id: string; companyId: string; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 997203890ef..62a34b3402b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -142,6 +142,22 @@ export { issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, + issueThreadInteractionStatusSchema, + issueThreadInteractionKindSchema, + issueThreadInteractionContinuationPolicySchema, + suggestedTaskDraftSchema, + suggestTasksPayloadSchema, + suggestTasksResultCreatedTaskSchema, + suggestTasksResultSchema, + askUserQuestionsQuestionOptionSchema, + askUserQuestionsQuestionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsAnswerSchema, + askUserQuestionsResultSchema, + createIssueThreadInteractionSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, issueDocumentFormatSchema, @@ -155,6 +171,10 @@ export { type IssueExecutionWorkspaceSettings, type CheckoutIssue, type AddIssueComment, + type CreateIssueThreadInteraction, + type AcceptIssueThreadInteraction, + type RejectIssueThreadInteraction, + type RespondIssueThreadInteraction, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type IssueDocumentFormat, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 6b11570dfb9..2381cbaacb0 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -6,6 +6,9 @@ import { ISSUE_EXECUTION_STATE_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES, + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, + ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_STATUSES, } from "../constants.js"; export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [ @@ -183,6 +186,164 @@ export const addIssueCommentSchema = z.object({ export type AddIssueComment = z.infer; +export const issueThreadInteractionStatusSchema = z.enum(ISSUE_THREAD_INTERACTION_STATUSES); +export const issueThreadInteractionKindSchema = z.enum(ISSUE_THREAD_INTERACTION_KINDS); +export const issueThreadInteractionContinuationPolicySchema = z.enum( + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, +); + +export const suggestedTaskDraftSchema = z.object({ + clientKey: z.string().trim().min(1).max(120), + parentClientKey: z.string().trim().min(1).max(120).nullable().optional(), + parentId: z.string().uuid().nullable().optional(), + title: z.string().trim().min(1).max(240), + description: z.string().trim().max(20000).nullable().optional(), + priority: z.enum(ISSUE_PRIORITIES).nullable().optional(), + assigneeAgentId: z.string().uuid().nullable().optional(), + assigneeUserId: z.string().trim().min(1).nullable().optional(), + projectId: z.string().uuid().nullable().optional(), + goalId: z.string().uuid().nullable().optional(), + billingCode: z.string().trim().max(120).nullable().optional(), + labels: z.array(z.string().trim().min(1).max(48)).max(20).optional(), + hiddenInPreview: z.boolean().optional(), +}).superRefine((value, ctx) => { + if (value.assigneeAgentId && value.assigneeUserId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Suggested tasks can only target one assignee", + path: ["assigneeAgentId"], + }); + } +}); + +export const suggestTasksPayloadSchema = z.object({ + version: z.literal(1), + defaultParentId: z.string().uuid().nullable().optional(), + tasks: z.array(suggestedTaskDraftSchema).min(1).max(50), +}).superRefine((value, ctx) => { + const seenClientKeys = new Set(); + for (const [index, task] of value.tasks.entries()) { + if (seenClientKeys.has(task.clientKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "clientKey must be unique within one interaction", + path: ["tasks", index, "clientKey"], + }); + continue; + } + seenClientKeys.add(task.clientKey); + } +}); + +export const suggestTasksResultCreatedTaskSchema = z.object({ + clientKey: z.string().trim().min(1).max(120), + issueId: z.string().uuid(), + identifier: z.string().trim().min(1).nullable().optional(), + title: z.string().trim().min(1).nullable().optional(), + parentIssueId: z.string().uuid().nullable().optional(), + parentIdentifier: z.string().trim().min(1).nullable().optional(), +}); + +export const suggestTasksResultSchema = z.object({ + version: z.literal(1), + createdTasks: z.array(suggestTasksResultCreatedTaskSchema).max(50).optional(), + rejectionReason: z.string().trim().max(4000).nullable().optional(), +}); + +export const askUserQuestionsQuestionOptionSchema = z.object({ + id: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).nullable().optional(), +}); + +export const askUserQuestionsQuestionSchema = z.object({ + id: z.string().trim().min(1).max(120), + prompt: z.string().trim().min(1).max(500), + helpText: z.string().trim().max(1000).nullable().optional(), + selectionMode: z.enum(["single", "multi"]), + required: z.boolean().optional(), + options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(10), +}); + +export const askUserQuestionsPayloadSchema = z.object({ + version: z.literal(1), + title: z.string().trim().max(240).nullable().optional(), + submitLabel: z.string().trim().max(120).nullable().optional(), + questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10), +}).superRefine((value, ctx) => { + const seenQuestionIds = new Set(); + for (const [questionIndex, question] of value.questions.entries()) { + if (seenQuestionIds.has(question.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Question ids must be unique within one interaction", + path: ["questions", questionIndex, "id"], + }); + } + seenQuestionIds.add(question.id); + + const seenOptionIds = new Set(); + for (const [optionIndex, option] of question.options.entries()) { + if (seenOptionIds.has(option.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Option ids must be unique within one question", + path: ["questions", questionIndex, "options", optionIndex, "id"], + }); + } + seenOptionIds.add(option.id); + } + } +}); + +export const askUserQuestionsAnswerSchema = z.object({ + questionId: z.string().trim().min(1).max(120), + optionIds: z.array(z.string().trim().min(1).max(120)).max(20), +}); + +export const askUserQuestionsResultSchema = z.object({ + version: z.literal(1), + answers: z.array(askUserQuestionsAnswerSchema).max(20), + summaryMarkdown: z.string().max(20000).nullable().optional(), +}); + +export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("suggest_tasks"), + 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"), + 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, + }), +]); + +export type CreateIssueThreadInteraction = z.infer; + +export const acceptIssueThreadInteractionSchema = z.object({}); +export type AcceptIssueThreadInteraction = z.infer; + +export const rejectIssueThreadInteractionSchema = z.object({ + reason: z.string().trim().max(4000).optional(), +}); +export type RejectIssueThreadInteraction = z.infer; + +export const respondIssueThreadInteractionSchema = z.object({ + answers: z.array(askUserQuestionsAnswerSchema).max(20), + summaryMarkdown: z.string().max(20000).nullable().optional(), +}); +export type RespondIssueThreadInteraction = z.infer; + export const linkIssueApprovalSchema = z.object({ approvalId: z.string().uuid(), }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts new file mode 100644 index 00000000000..52b09147238 --- /dev/null +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -0,0 +1,338 @@ +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(), + acceptSuggestedTasks: 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() })), +})); + +vi.mock("../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: () => ({}), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockInteractionService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createIssue(overrides: Record = {}) { + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1714", + title: "Persist interactions", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +async function createApp(actor: Record = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, +}) { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/issues.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +describe("issue thread interaction routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.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", + 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.acceptSuggestedTasks.mockResolvedValue({ + interaction: { + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "accepted", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + createdTasks: [{ clientKey: "task-1", issueId: "child-1" }], + }, + 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.rejectSuggestedTasks.mockResolvedValue({ + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "rejected", + continuationPolicy: "wake_assignee", + 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", + 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(mockInteractionService.listForIssue).toHaveBeenCalledWith("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + + 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({}); + + expect(res.status).toBe(200); + expect(mockInteractionService.acceptSuggestedTasks).toHaveBeenCalled(); + 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", + }), + }), + ); + }); + + 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", + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_answered", + }), + ); + }); + + it("rejects mutation requests from non-board actors", 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", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + + expect(res.status).toBe(403); + expect(mockInteractionService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts new file mode 100644 index 00000000000..11b518ef037 --- /dev/null +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -0,0 +1,249 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + goals, + instanceSettings, + issueRelations, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { issueService } from "../services/issues.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("issueThreadInteractionService", () => { + let db!: ReturnType; + let issuesSvc!: ReturnType; + let interactionsSvc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-"); + db = createDb(tempDb.connectionString); + issuesSvc = issueService(db); + interactionsSvc = issueThreadInteractionService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueThreadInteractions); + await db.delete(issueRelations); + 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"); + }); + + 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.", + }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a50531502df..2f5f33e8c0b 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6,7 +6,9 @@ import type { Db } from "@paperclipai/db"; import { issueExecutionDecisions } from "@paperclipai/db"; import { addIssueCommentSchema, + acceptIssueThreadInteractionSchema, createIssueAttachmentMetadataSchema, + createIssueThreadInteractionSchema, createIssueWorkProductSchema, createIssueLabelSchema, checkoutIssueSchema, @@ -19,7 +21,9 @@ import { linkIssueApprovalSchema, issueDocumentKeySchema, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, + respondIssueThreadInteractionSchema, updateIssueWorkProductSchema, upsertIssueDocumentSchema, updateIssueSchema, @@ -40,6 +44,7 @@ import { heartbeatService, instanceSettingsService, issueApprovalService, + issueThreadInteractionService, ISSUE_LIST_DEFAULT_LIMIT, ISSUE_LIST_MAX_LIMIT, issueReferenceService, @@ -53,7 +58,7 @@ import { } from "../services/index.js"; import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; -import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectIssueWorkspaceCommandPaths, @@ -185,6 +190,46 @@ function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: { return true; } +function queueResolvedInteractionContinuationWakeup(input: { + heartbeat: ReturnType; + issue: { id: string; assigneeAgentId: string | null; status: string }; + interaction: { id: string; kind: string; status: string; continuationPolicy: string }; + actor: { actorType: "user" | "agent"; actorId: string }; + source: string; +}) { + if (input.interaction.continuationPolicy !== "wake_assignee") 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, + 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, + 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, @@ -2440,6 +2485,222 @@ 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); + assertBoard(req); + + const actor = getActorInfo(req); + const interaction = await issueThreadInteractionService(db).create(issue, 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_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 } = await issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, { + 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_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, + }, + }); + + 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, + 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).rejectSuggestedTasks(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_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) + : 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; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 7b445c272d4..f614c14ade2 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -19,6 +19,7 @@ export { issueService, type IssueFilters, } from "./issues.js"; +export { issueThreadInteractionService } from "./issue-thread-interactions.js"; export { issueApprovalService } from "./issue-approvals.js"; export { issueReferenceService } from "./issue-references.js"; export { goalService } from "./goals.js"; diff --git a/server/src/services/issue-thread-interactions.test.ts b/server/src/services/issue-thread-interactions.test.ts new file mode 100644 index 00000000000..0ba086420b9 --- /dev/null +++ b/server/src/services/issue-thread-interactions.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCreateChild = vi.fn(); + +vi.mock("./issues.js", () => ({ + issueService: () => ({ + createChild: mockCreateChild, + }), +})); + +type SelectRow = Record; + +function createSelectChain(rows: SelectRow[]) { + return { + from() { + return { + where() { + return { + then(callback: (rows: SelectRow[]) => unknown) { + return Promise.resolve(callback(rows)); + }, + }; + }, + }; + }, + }; +} + +function createFakeDb(args: { + interactionRow: Record; + parentRows?: SelectRow[]; +}) { + let interactionRow = { ...args.interactionRow }; + const issueTouches: Array> = []; + const interactionUpdates: Array> = []; + let selectCallCount = 0; + + const db: any = { + select: vi.fn(() => { + selectCallCount += 1; + return createSelectChain(selectCallCount === 1 ? [interactionRow] : (args.parentRows ?? [])); + }), + update: vi.fn((table: unknown) => ({ + set(values: Record) { + return { + where() { + if ("status" in values || "result" in values || "resolvedAt" in values) { + interactionUpdates.push(values); + interactionRow = { ...interactionRow, ...values }; + return { + returning: async () => [interactionRow], + }; + } + if ("updatedAt" in values) { + issueTouches.push(values); + return Promise.resolve(undefined); + } + throw new Error(`Unexpected update target: ${String(table)}`); + }, + }; + }, + })), + insert: vi.fn(), + transaction: async (callback: (tx: typeof db) => Promise) => callback(db), + }; + + return { + db, + getInteractionRow: () => interactionRow, + issueTouches, + interactionUpdates, + }; +} + +describe("issueThreadInteractionService", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("answerQuestions normalizes duplicate option ids and persists answered results", async () => { + const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); + + const interactionRow = { + id: "interaction-2", + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + sourceCommentId: null, + sourceRunId: null, + title: null, + summary: null, + createdByAgentId: null, + createdByUserId: "local-board", + resolvedByAgentId: null, + resolvedByUserId: null, + payload: { + version: 1, + questions: [ + { + id: "scope", + prompt: "Pick one scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }, + { + id: "extras", + prompt: "Pick extras", + selectionMode: "multi", + options: [ + { id: "tests", label: "Tests" }, + { id: "docs", label: "Docs" }, + ], + }, + ], + }, + result: null, + resolvedAt: null, + createdAt: new Date("2026-04-20T10:00:00.000Z"), + updatedAt: new Date("2026-04-20T10:00:00.000Z"), + }; + const state = createFakeDb({ interactionRow }); + const svc = issueThreadInteractionService(state.db as never); + + const result = await svc.answerQuestions({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + }, "interaction-2", { + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, + ], + summaryMarkdown: "Phase 1 with tests and docs.", + }, { + userId: "local-board", + }); + + expect(result.status).toBe("answered"); + expect(result.result).toEqual({ + version: 1, + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests"] }, + ], + summaryMarkdown: "Phase 1 with tests and docs.", + }); + expect(state.interactionUpdates).toHaveLength(1); + expect(state.issueTouches).toHaveLength(1); + }); +}); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts new file mode 100644 index 00000000000..cc62a9f4c2c --- /dev/null +++ b/server/src/services/issue-thread-interactions.ts @@ -0,0 +1,449 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + heartbeatRuns, + issueComments, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import type { + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + CreateIssueThreadInteraction, + IssueThreadInteraction, + RejectIssueThreadInteraction, + RespondIssueThreadInteraction, + SuggestTasksInteraction, + SuggestTasksResultCreatedTask, +} from "@paperclipai/shared"; +import { + askUserQuestionsPayloadSchema, + askUserQuestionsResultSchema, + createIssueThreadInteractionSchema, + suggestTasksPayloadSchema, + suggestTasksResultSchema, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; +import { issueService } from "./issues.js"; + +type InteractionActor = { + agentId?: string | null; + userId?: string | null; +}; + +type CreatedIssueWakeTarget = { + id: string; + assigneeAgentId: string | null; + status: string; +}; + +function hydrateInteraction( + row: typeof issueThreadInteractions.$inferSelect, +): IssueThreadInteraction { + const base = { + ...row, + status: row.status as IssueThreadInteraction["status"], + continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"], + }; + + switch (row.kind) { + case "suggest_tasks": + return { + ...base, + kind: "suggest_tasks", + payload: suggestTasksPayloadSchema.parse(row.payload), + result: row.result ? suggestTasksResultSchema.parse(row.result) : null, + } satisfies SuggestTasksInteraction; + case "ask_user_questions": + return { + ...base, + kind: "ask_user_questions", + payload: askUserQuestionsPayloadSchema.parse(row.payload), + result: row.result ? askUserQuestionsResultSchema.parse(row.result) : null, + } satisfies AskUserQuestionsInteraction; + default: + throw unprocessable(`Unknown interaction kind: ${row.kind}`); + } +} + +async function touchIssue(db: Db | any, issueId: string) { + await db + .update(issues) + .set({ updatedAt: new Date() }) + .where(eq(issues.id, issueId)); +} + +function buildTaskCreationOrder(tasks: ReadonlyArray) { + const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const)); + const ordered: Array = []; + const state = new Map(); + + const visit = (clientKey: string) => { + const currentState = state.get(clientKey); + if (currentState === "done") return; + if (currentState === "visiting") { + throw unprocessable("Suggested tasks contain a parentClientKey cycle"); + } + + const task = taskByClientKey.get(clientKey); + if (!task) { + throw unprocessable(`Unknown parentClientKey: ${clientKey}`); + } + + state.set(clientKey, "visiting"); + if (task.parentClientKey) { + visit(task.parentClientKey); + } + state.set(clientKey, "done"); + ordered.push(task); + }; + + for (const task of tasks) { + visit(task.clientKey); + } + + return ordered; +} + +function normalizeQuestionAnswers(args: { + questions: AskUserQuestionsInteraction["payload"]["questions"]; + answers: RespondIssueThreadInteraction["answers"]; +}) { + const questionById = new Map(args.questions.map((question) => [question.id, question] as const)); + const answerByQuestionId = new Map(); + + for (const answer of args.answers) { + const question = questionById.get(answer.questionId); + if (!question) { + throw unprocessable(`Unknown questionId: ${answer.questionId}`); + } + if (answerByQuestionId.has(answer.questionId)) { + throw unprocessable(`Duplicate answer for questionId: ${answer.questionId}`); + } + + const uniqueOptionIds = [...new Set(answer.optionIds)]; + const validOptionIds = new Set(question.options.map((option) => option.id)); + for (const optionId of uniqueOptionIds) { + if (!validOptionIds.has(optionId)) { + throw unprocessable(`Unknown optionId for question ${answer.questionId}: ${optionId}`); + } + } + + if (question.selectionMode === "single" && uniqueOptionIds.length > 1) { + throw unprocessable(`Question ${answer.questionId} only allows one answer`); + } + + answerByQuestionId.set(answer.questionId, { + questionId: answer.questionId, + optionIds: uniqueOptionIds, + }); + } + + for (const question of args.questions) { + const answer = answerByQuestionId.get(question.id); + if (question.required && (!answer || answer.optionIds.length === 0)) { + throw unprocessable(`Question ${question.id} requires an answer`); + } + } + + return args.questions + .map((question) => answerByQuestionId.get(question.id)) + .filter((answer): answer is AskUserQuestionsAnswer => Boolean(answer)); +} + +export function issueThreadInteractionService(db: Db) { + return { + listForIssue: async (issueId: string) => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.issueId, issueId)) + .orderBy(asc(issueThreadInteractions.createdAt), asc(issueThreadInteractions.id)); + + return rows.map((row) => hydrateInteraction(row)); + }, + + getById: async (interactionId: string) => { + const row = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + return row ? hydrateInteraction(row) : null; + }, + + create: async ( + issue: { id: string; companyId: string }, + input: CreateIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = createIssueThreadInteractionSchema.parse(input); + + if (data.sourceCommentId) { + const sourceComment = await db + .select({ + companyId: issueComments.companyId, + issueId: issueComments.issueId, + }) + .from(issueComments) + .where(eq(issueComments.id, data.sourceCommentId)) + .then((rows) => rows[0] ?? null); + if (!sourceComment || sourceComment.companyId !== issue.companyId || sourceComment.issueId !== issue.id) { + throw unprocessable("sourceCommentId must belong to the same issue and company"); + } + } + + if (data.sourceRunId) { + const sourceRun = await db + .select({ + companyId: heartbeatRuns.companyId, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, data.sourceRunId)) + .then((rows) => rows[0] ?? null); + if (!sourceRun || sourceRun.companyId !== issue.companyId) { + throw unprocessable("sourceRunId must belong to the same company"); + } + } + + const [created] = await db + .insert(issueThreadInteractions) + .values({ + companyId: issue.companyId, + issueId: issue.id, + kind: data.kind, + status: "pending", + continuationPolicy: data.continuationPolicy, + sourceCommentId: data.sourceCommentId ?? null, + sourceRunId: data.sourceRunId ?? null, + title: data.title ?? null, + summary: data.summary ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + payload: data.payload, + }) + .returning(); + + await touchIssue(db, issue.id); + return hydrateInteraction(created); + }, + + acceptSuggestedTasks: async ( + issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, + interactionId: string, + actor: InteractionActor, + ) => { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "suggest_tasks") { + throw unprocessable("Only suggest_tasks interactions can be accepted"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const interaction = hydrateInteraction(current) as SuggestTasksInteraction; + const orderedTasks = buildTaskCreationOrder(interaction.payload.tasks); + const explicitParentIds = [...new Set([ + issue.id, + ...(interaction.payload.defaultParentId ? [interaction.payload.defaultParentId] : []), + ...interaction.payload.tasks + .map((task) => task.parentId ?? null) + .filter((value): value is string => Boolean(value)), + ])]; + + const parentRows = explicitParentIds.length === 0 + ? [] + : await db + .select({ + id: issues.id, + identifier: issues.identifier, + companyId: issues.companyId, + }) + .from(issues) + .where(and(eq(issues.companyId, issue.companyId), inArray(issues.id, explicitParentIds))); + if (parentRows.length !== explicitParentIds.length) { + throw unprocessable("Suggested tasks reference parent issues outside this company or issue tree"); + } + + const parentById = new Map(parentRows.map((row) => [row.id, row] as const)); + const createdByClientKey = new Map(); + const createdWakeTargets: CreatedIssueWakeTarget[] = []; + + await db.transaction(async (tx) => { + for (const task of orderedTasks) { + const parentIssueId = task.parentClientKey + ? createdByClientKey.get(task.parentClientKey)?.issueId ?? null + : task.parentId ?? interaction.payload.defaultParentId ?? issue.id; + if (!parentIssueId) { + throw unprocessable(`Unable to resolve parent for suggested task ${task.clientKey}`); + } + + const { issue: createdIssue } = await issueService(tx as unknown as Db).createChild(parentIssueId, { + title: task.title, + description: task.description ?? null, + status: "todo", + priority: task.priority ?? "medium", + assigneeAgentId: task.assigneeAgentId ?? null, + assigneeUserId: task.assigneeUserId ?? null, + projectId: task.projectId ?? issue.projectId, + goalId: task.goalId ?? issue.goalId, + billingCode: task.billingCode ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + actorAgentId: actor.agentId ?? null, + actorUserId: actor.userId ?? null, + } as Parameters["createChild"]>[1]); + + const parentIdentifier = createdByClientKey.get(task.parentClientKey ?? "")?.identifier + ?? parentById.get(parentIssueId)?.identifier + ?? null; + createdByClientKey.set(task.clientKey, { + clientKey: task.clientKey, + issueId: createdIssue.id, + identifier: createdIssue.identifier ?? null, + title: createdIssue.title, + parentIssueId, + parentIdentifier, + }); + createdWakeTargets.push({ + id: createdIssue.id, + assigneeAgentId: createdIssue.assigneeAgentId ?? null, + status: createdIssue.status, + }); + } + + const [updated] = await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + result: { + version: 1, + createdTasks: [...createdByClientKey.values()], + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(issueThreadInteractions.id, interactionId)) + .returning(); + + await touchIssue(tx, issue.id); + current.status = updated.status; + current.result = updated.result; + current.resolvedByAgentId = updated.resolvedByAgentId; + current.resolvedByUserId = updated.resolvedByUserId; + current.resolvedAt = updated.resolvedAt; + current.updatedAt = updated.updatedAt; + }); + + return { + interaction: hydrateInteraction(current), + createdIssues: createdWakeTargets, + }; + }, + + rejectSuggestedTasks: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RejectIssueThreadInteraction, + actor: InteractionActor, + ) => { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "suggest_tasks") { + throw unprocessable("Only suggest_tasks interactions can be rejected"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "rejected", + result: { + version: 1, + rejectionReason: input.reason?.trim() || null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(issueThreadInteractions.id, interactionId)) + .returning(); + + await touchIssue(db, issue.id); + return hydrateInteraction(updated); + }, + + answerQuestions: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RespondIssueThreadInteraction, + actor: InteractionActor, + ) => { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "ask_user_questions") { + throw unprocessable("Only ask_user_questions interactions can be answered"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const interaction = hydrateInteraction(current) as AskUserQuestionsInteraction; + const normalizedAnswers = normalizeQuestionAnswers({ + questions: interaction.payload.questions, + answers: input.answers, + }); + + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "answered", + result: { + version: 1, + answers: normalizedAnswers, + summaryMarkdown: input.summaryMarkdown ?? null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(issueThreadInteractions.id, interactionId)) + .returning(); + + await touchIssue(db, issue.id); + return hydrateInteraction(updated); + }, + }; +} diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index ba456af9af1..782c49216ae 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,4 +1,5 @@ import type { + AskUserQuestionsAnswer, Approval, DocumentRevision, FeedbackTargetType, @@ -9,7 +10,9 @@ import type { IssueComment, IssueDocument, IssueLabel, + IssueThreadInteraction, IssueWorkProduct, + SuggestTasksInteraction, UpsertIssueDocument, } from "@paperclipai/shared"; import { api } from "./client"; @@ -99,6 +102,20 @@ export const issuesApi = { const qs = params.toString(); return api.get(`/issues/${id}/comments${qs ? `?${qs}` : ""}`); }, + listInteractions: (id: string) => + api.get(`/issues/${id}/interactions`), + createInteraction: (id: string, data: Record) => + api.post(`/issues/${id}/interactions`, data), + acceptInteraction: (id: string, interactionId: string) => + api.post(`/issues/${id}/interactions/${interactionId}/accept`, {}), + rejectInteraction: (id: string, interactionId: string, reason?: string) => + api.post(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}), + respondToInteraction: ( + id: string, + interactionId: string, + data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null }, + ) => + api.post(`/issues/${id}/interactions/${interactionId}/respond`, data), getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index f00c3f2755e..601110da80d 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -12,6 +12,10 @@ import { resolveAssistantMessageFoldedState, resolveIssueChatHumanAuthor, } from "./IssueChatThread"; +import type { + AskUserQuestionsInteraction, + SuggestTasksInteraction, +} from "../lib/issue-thread-interactions"; const { markdownEditorFocusMock } = vi.hoisted(() => ({ markdownEditorFocusMock: vi.fn(), @@ -139,6 +143,78 @@ vi.mock("../hooks/usePaperclipIssueRuntime", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +function createSuggestedTasksInteraction( + overrides: Partial = {}, +): SuggestTasksInteraction { + return { + id: "interaction-suggest-1", + companyId: "company-1", + issueId: "issue-1", + kind: "suggest_tasks", + title: "Suggested follow-up work", + summary: "Preview the next issue tree before accepting it.", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + createdAt: new Date("2026-04-06T12:02:00.000Z"), + updatedAt: new Date("2026-04-06T12:02:00.000Z"), + resolvedAt: null, + payload: { + version: 1, + tasks: [ + { + clientKey: "task-1", + title: "Prototype the card", + }, + ], + }, + result: null, + ...overrides, + }; +} + +function createQuestionInteraction( + overrides: Partial = {}, +): AskUserQuestionsInteraction { + return { + id: "interaction-question-1", + companyId: "company-1", + issueId: "issue-1", + kind: "ask_user_questions", + title: "Clarify the phase", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + createdAt: new Date("2026-04-06T12:03:00.000Z"), + updatedAt: new Date("2026-04-06T12:03:00.000Z"), + resolvedAt: null, + payload: { + version: 1, + submitLabel: "Submit answers", + questions: [ + { + id: "scope", + prompt: "Pick one scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }, + ], + }, + result: null, + ...overrides, + }; +} + describe("IssueChatThread", () => { let container: HTMLDivElement; @@ -300,6 +376,100 @@ describe("IssueChatThread", () => { }); }); + it("invokes the accept callback for pending suggested-task interactions", async () => { + const root = createRoot(container); + const onAcceptInteraction = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onAcceptInteraction={onAcceptInteraction} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const acceptButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Accept drafts"), + ); + expect(acceptButton).toBeTruthy(); + + await act(async () => { + acceptButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onAcceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-suggest-1", + kind: "suggest_tasks", + }), + ); + + act(() => { + root.unmount(); + }); + }); + + it("submits selected answers for pending question interactions", async () => { + const root = createRoot(container); + const onSubmitInteractionAnswers = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onSubmitInteractionAnswers={onSubmitInteractionAnswers} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const optionButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Phase 1"), + ); + const submitButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Submit answers"), + ); + expect(optionButton).toBeTruthy(); + expect(submitButton).toBeTruthy(); + + await act(async () => { + optionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSubmitInteractionAnswers).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-question-1", + kind: "ask_user_questions", + }), + [{ questionId: "scope", optionIds: ["phase-1"] }], + ); + + act(() => { + root.unmount(); + }); + }); + it("renders the transcript directly from stable Paperclip messages", () => { const root = createRoot(container); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 7be15ad30a1..4a02ccf7ac8 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -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; diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts index b8a50ec53a1..1b7f35a0436 100644 --- a/ui/src/lib/issue-thread-interactions.ts +++ b/ui/src/lib/issue-thread-interactions.ts @@ -1,120 +1,30 @@ -export type IssueThreadInteractionStatus = - | "pending" - | "accepted" - | "rejected" - | "answered" - | "expired" - | "failed"; - -export type IssueThreadInteractionContinuationPolicy = "none" | "wake_assignee"; - -export interface IssueThreadInteractionActorFields { - createdByAgentId?: string | null; - createdByUserId?: string | null; - resolvedByAgentId?: string | null; - resolvedByUserId?: string | null; -} - -export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { - id: string; - companyId: string; - issueId: string; - 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 SuggestedTaskDraft { - clientKey: string; - parentClientKey?: string | null; - parentId?: string | null; - title: string; - description?: string | null; - priority?: "critical" | "high" | "medium" | "low" | 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[]; - rejectionReason?: string | null; -} - -export interface SuggestTasksInteraction extends IssueThreadInteractionBase { - kind: "suggest_tasks"; - payload: SuggestTasksPayload; - result?: SuggestTasksResult | 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 AskUserQuestionsInteraction extends IssueThreadInteractionBase { - kind: "ask_user_questions"; - payload: AskUserQuestionsPayload; - result?: AskUserQuestionsResult | null; -} - -export type IssueThreadInteraction = - | SuggestTasksInteraction - | AskUserQuestionsInteraction; +export type { + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + AskUserQuestionsPayload, + AskUserQuestionsQuestion, + AskUserQuestionsQuestionOption, + AskUserQuestionsResult, + IssueThreadInteraction, + IssueThreadInteractionActorFields, + IssueThreadInteractionBase, + IssueThreadInteractionContinuationPolicy, + IssueThreadInteractionStatus, + SuggestedTaskDraft, + SuggestTasksInteraction, + SuggestTasksPayload, + SuggestTasksResult, + SuggestTasksResultCreatedTask, +} from "@paperclipai/shared"; +import type { + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + AskUserQuestionsQuestion, + IssueThreadInteraction, + SuggestedTaskDraft, + SuggestTasksInteraction, + SuggestTasksResultCreatedTask, +} from "@paperclipai/shared"; export interface SuggestedTaskTreeNode { task: SuggestedTaskDraft; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index a55f1d86c55..7fbec20f3fe 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f6276248872..b1982b810ad 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -112,12 +112,15 @@ 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 SuggestTasksInteraction, } from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; @@ -509,6 +512,7 @@ type IssueDetailChatTabProps = { blockedBy: Issue["blockedBy"]; comments: IssueDetailComment[]; locallyQueuedCommentRunIds: ReadonlyMap; + interactions: IssueThreadInteraction[]; hasOlderComments: boolean; commentsLoadingOlder: boolean; onLoadOlderComments: () => void; @@ -538,6 +542,12 @@ type IssueDetailChatTabProps = { onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; onImageClick: (src: string) => void; + onAcceptInteraction: (interaction: SuggestTasksInteraction) => Promise; + onRejectInteraction: (interaction: SuggestTasksInteraction, reason?: string) => Promise; + onSubmitInteractionAnswers: ( + interaction: IssueThreadInteraction, + answers: AskUserQuestionsAnswer[], + ) => Promise; }; const IssueDetailChatTab = memo(function IssueDetailChatTab({ @@ -549,6 +559,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ blockedBy, comments, locallyQueuedCommentRunIds, + interactions, hasOlderComments, commentsLoadingOlder, onLoadOlderComments, @@ -574,6 +585,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onCancelQueued, interruptingQueuedRunId, onImageClick, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, }: IssueDetailChatTabProps) { const { data: activity } = useQuery({ queryKey: queryKeys.issues.activity(issueId), @@ -704,6 +718,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ + onSubmitInteractionAnswers(interaction, answers) + } onCancelRun={runningIssueRun ? async () => { await onInterruptQueued(runningIssueRun.id); @@ -1006,6 +1026,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(issueId ?? "pending"), + }); const { data: attachments, isLoading: attachmentsLoading } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), @@ -1207,10 +1233,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 +1273,22 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } }, [queryClient, selectedCompanyId]); + const upsertInteractionInCache = useCallback((interaction: IssueThreadInteraction) => { + queryClient.setQueryData( + 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, data: Record) => { queryClient.setQueriesData( @@ -1495,6 +1539,74 @@ export function IssueDetail() { } }, }); + const acceptInteraction = useMutation({ + mutationFn: (interaction: SuggestTasksInteraction) => + issuesApi.acceptInteraction(issueId!, interaction.id), + onSuccess: (interaction) => { + upsertInteractionInCache(interaction); + if (resolvedCompanyId && issue?.id) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(resolvedCompanyId, issue.id) }); + } + invalidateIssueDetail(); + invalidateIssueCollections(); + pushToast({ + title: "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: SuggestTasksInteraction; reason?: string }) => + issuesApi.rejectInteraction(issueId!, interaction.id, reason), + onSuccess: (interaction) => { + upsertInteractionInCache(interaction); + invalidateIssueDetail(); + invalidateIssueCollections(); + pushToast({ + title: "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 +2336,18 @@ export function IssueDetail() { const handleInterruptQueuedRun = useCallback(async (runId: string) => { await interruptQueuedComment.mutateAsync(runId); }, [interruptQueuedComment]); + const handleAcceptInteraction = useCallback(async (interaction: SuggestTasksInteraction) => { + await acceptInteraction.mutateAsync(interaction); + }, [acceptInteraction]); + const handleRejectInteraction = useCallback(async (interaction: SuggestTasksInteraction, 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 ; if (error) return

{error.message}

; @@ -2775,6 +2899,7 @@ export function IssueDetail() { blockedBy={issue.blockedBy ?? []} comments={threadComments} locallyQueuedCommentRunIds={locallyQueuedCommentRunIds} + interactions={interactions} hasOlderComments={hasOlderComments} commentsLoadingOlder={commentsLoadingOlder} onLoadOlderComments={loadOlderComments} @@ -2800,6 +2925,9 @@ export function IssueDetail() { onCancelQueued={handleCancelQueuedComment} interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} onImageClick={handleChatImageClick} + onAcceptInteraction={handleAcceptInteraction} + onRejectInteraction={handleRejectInteraction} + onSubmitInteractionAnswers={handleSubmitInteractionAnswers} /> ) : null}