mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Persist issue thread interactions and board actions
This commit is contained in:
1 parent
98dab55fe0
commit
b23ac4fd79
22 files changed
+2305
-118
No files matched your search
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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,
|
||||
),
|
||||
sourceCommentIdx: index("issue_thread_interactions_source_comment_idx").on(table.sourceCommentId),
|
||||
}),
|
||||
);
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 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(),
|
||||
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(),
|
||||
});
|
||||
|
||||
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<typeof createIssueThreadInteractionSchema>;
|
||||
|
||||
export const acceptIssueThreadInteractionSchema = z.object({});
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -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<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.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();
|
||||
});
|
||||
});
|
||||
@@ -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<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(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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
+262
-1
@@ -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<typeof heartbeatService>;
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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("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);
|
||||
});
|
||||
});
|
||||
@@ -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<SuggestTasksInteraction["payload"]["tasks"][number]>) {
|
||||
const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const));
|
||||
const ordered: Array<SuggestTasksInteraction["payload"]["tasks"][number]> = [];
|
||||
const state = new Map<string, "visiting" | "done">();
|
||||
|
||||
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<string, AskUserQuestionsAnswer>();
|
||||
|
||||
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<string, SuggestTasksResultCreatedTask>();
|
||||
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<ReturnType<typeof issueService>["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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<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) =>
|
||||
api.post<SuggestTasksInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, {}),
|
||||
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<SuggestTasksInteraction>(`/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,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(
|
||||
<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",
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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<string, string>;
|
||||
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<void>;
|
||||
onRejectInteraction: (interaction: SuggestTasksInteraction, reason?: string) => Promise<void>;
|
||||
onSubmitInteractionAnswers: (
|
||||
interaction: IssueThreadInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
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({
|
||||
<IssueChatThread
|
||||
composerRef={composerRef}
|
||||
comments={commentsWithRunMeta}
|
||||
interactions={interactions}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
@@ -735,6 +750,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 +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<IssueThreadInteraction[]>(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<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 +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 <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
@@ -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}
|
||||
</TabsContent>
|
||||
|
||||
Reference in new issue
Block a user