Persist issue thread interactions and board actions

This commit is contained in:
Dotta committed 2026-04-21 17:31:48 -05:00
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
}
]
}
+1
View File
@@ -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),
}),
);
+23
View File
@@ -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}`;
+42
View File
@@ -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,
+16
View File
@@ -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,
+120
View File
@@ -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;
+20
View File
@@ -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,
+161
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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);
},
};
}
+17
View File
@@ -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`),
+170
View File
@@ -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);
+3
View File
@@ -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;
+27 -117
View File
@@ -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;
+1
View File
@@ -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,
+128
View File
@@ -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>