Add request confirmation interactions

This commit is contained in:
Dotta committed 2026-04-21 17:33:57 -05:00
1 parent b51a0273b6
commit 4acaebddc0
11 files changed
+1038 -17

No files matched your search

+1
View File
@@ -140,6 +140,7 @@ export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const ISSUE_THREAD_INTERACTION_KINDS = [
"suggest_tasks",
"ask_user_questions",
"request_confirmation",
] as const;
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
+9
View File
@@ -316,9 +316,14 @@ export type {
AskUserQuestionsPayload,
AskUserQuestionsAnswer,
AskUserQuestionsResult,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
IssueThreadInteractionBase,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
@@ -589,6 +594,10 @@ export {
askUserQuestionsPayloadSchema,
askUserQuestionsAnswerSchema,
askUserQuestionsResultSchema,
requestConfirmationIssueDocumentTargetSchema,
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { createIssueThreadInteractionSchema } from "./validators/issue.js";
describe("issue thread interaction schemas", () => {
it("parses request_confirmation payloads with default no-wake continuation", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Revise",
detailsMarkdown: "The current plan document will be accepted as-is.",
supersedeOnUserComment: true,
},
});
expect(parsed).toMatchObject({
kind: "request_confirmation",
continuationPolicy: "none",
payload: {
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Revise",
supersedeOnUserComment: true,
},
});
});
it("accepts issue document targets for request_confirmation interactions", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Accept the latest plan revision?",
target: {
type: "issue_document",
issueId: "11111111-1111-4111-8111-111111111111",
documentId: "22222222-2222-4222-8222-222222222222",
key: "plan",
revisionId: "33333333-3333-4333-8333-333333333333",
revisionNumber: 2,
},
},
});
expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({
type: "issue_document",
key: "plan",
revisionNumber: 2,
});
});
});
+5
View File
@@ -124,9 +124,14 @@ export type {
AskUserQuestionsPayload,
AskUserQuestionsAnswer,
AskUserQuestionsResult,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
IssueThreadInteractionBase,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
+41 -3
View File
@@ -344,6 +344,35 @@ export interface AskUserQuestionsResult {
summaryMarkdown?: string | null;
}
export interface RequestConfirmationIssueDocumentTarget {
type: "issue_document";
issueId?: string | null;
documentId?: string | null;
key: string;
revisionId: string;
revisionNumber?: number | null;
}
export type RequestConfirmationTarget = RequestConfirmationIssueDocumentTarget;
export interface RequestConfirmationPayload {
version: 1;
prompt: string;
acceptLabel?: string | null;
rejectLabel?: string | null;
detailsMarkdown?: string | null;
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
}
export interface RequestConfirmationResult {
version: 1;
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
reason?: string | null;
commentId?: string | null;
staleTarget?: RequestConfirmationTarget | null;
}
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string;
companyId: string;
@@ -373,17 +402,26 @@ export interface AskUserQuestionsInteraction extends IssueThreadInteractionBase
result?: AskUserQuestionsResult | null;
}
export interface RequestConfirmationInteraction extends IssueThreadInteractionBase {
kind: "request_confirmation";
payload: RequestConfirmationPayload;
result?: RequestConfirmationResult | null;
}
export type IssueThreadInteraction =
| SuggestTasksInteraction
| AskUserQuestionsInteraction;
| AskUserQuestionsInteraction
| RequestConfirmationInteraction;
export type IssueThreadInteractionPayload =
| SuggestTasksPayload
| AskUserQuestionsPayload;
| AskUserQuestionsPayload
| RequestConfirmationPayload;
export type IssueThreadInteractionResult =
| SuggestTasksResult
| AskUserQuestionsResult;
| AskUserQuestionsResult
| RequestConfirmationResult;
export interface IssueAttachment {
id: string;
+4
View File
@@ -154,6 +154,10 @@ export {
askUserQuestionsPayloadSchema,
askUserQuestionsAnswerSchema,
askUserQuestionsResultSchema,
requestConfirmationIssueDocumentTargetSchema,
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
+48 -7
View File
@@ -192,6 +192,13 @@ export const issueThreadInteractionContinuationPolicySchema = z.enum(
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
);
export const issueDocumentKeySchema = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -");
export const suggestedTaskDraftSchema = z.object({
clientKey: z.string().trim().min(1).max(120),
parentClientKey: z.string().trim().min(1).max(120).nullable().optional(),
@@ -308,6 +315,37 @@ export const askUserQuestionsResultSchema = z.object({
summaryMarkdown: z.string().max(20000).nullable().optional(),
});
export const requestConfirmationIssueDocumentTargetSchema = z.object({
type: z.literal("issue_document"),
issueId: z.string().uuid().nullable().optional(),
documentId: z.string().uuid().nullable().optional(),
key: issueDocumentKeySchema,
revisionId: z.string().uuid(),
revisionNumber: z.number().int().positive().nullable().optional(),
});
export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [
requestConfirmationIssueDocumentTargetSchema,
]);
export const requestConfirmationPayloadSchema = z.object({
version: z.literal(1),
prompt: z.string().trim().min(1).max(1000),
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
detailsMarkdown: z.string().max(20000).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
});
export const requestConfirmationResultSchema = z.object({
version: z.literal(1),
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
reason: z.string().trim().max(4000).nullable().optional(),
commentId: z.string().uuid().nullable().optional(),
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("suggest_tasks"),
@@ -329,6 +367,16 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: askUserQuestionsPayloadSchema,
}),
z.object({
kind: z.literal("request_confirmation"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
payload: requestConfirmationPayloadSchema,
}),
]);
export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
@@ -378,13 +426,6 @@ export const ISSUE_DOCUMENT_FORMATS = ["markdown"] as const;
export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS);
export const issueDocumentKeySchema = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -");
export const upsertIssueDocumentSchema = z.object({
title: z.string().trim().max(200).nullable().optional(),
format: issueDocumentFormatSchema,
@@ -12,7 +12,9 @@ const mockIssueService = vi.hoisted(() => ({
const mockInteractionService = vi.hoisted(() => ({
listForIssue: vi.fn(),
create: vi.fn(),
acceptInteraction: vi.fn(),
acceptSuggestedTasks: vi.fn(),
rejectInteraction: vi.fn(),
rejectSuggestedTasks: vi.fn(),
answerQuestions: vi.fn(),
}));
@@ -163,7 +165,7 @@ describe("issue thread interaction routes", () => {
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:00:00.000Z",
});
mockInteractionService.acceptSuggestedTasks.mockResolvedValue({
mockInteractionService.acceptInteraction.mockResolvedValue({
interaction: {
id: "interaction-1",
companyId: "company-1",
@@ -195,7 +197,7 @@ describe("issue thread interaction routes", () => {
},
],
});
mockInteractionService.rejectSuggestedTasks.mockResolvedValue({
mockInteractionService.rejectInteraction.mockResolvedValue({
id: "interaction-1",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
@@ -290,7 +292,7 @@ describe("issue thread interaction routes", () => {
.send({ selectedClientKeys: ["task-1"] });
expect(res.status).toBe(200);
expect(mockInteractionService.acceptSuggestedTasks).toHaveBeenCalledWith(
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-1",
{ selectedClientKeys: ["task-1"] },
@@ -358,6 +360,87 @@ describe("issue thread interaction routes", () => {
);
});
it("accepts request confirmations and wakes the current assignee when configured", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-3",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-3",
payload: {
version: 1,
prompt: "Apply this plan?",
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-3",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
}),
}),
);
});
it("does not emit a continuation wake when request confirmations are rejected", async () => {
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
id: "interaction-3",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "rejected",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-3",
payload: {
version: 1,
prompt: "Apply this plan?",
},
result: {
version: 1,
outcome: "rejected",
reason: "Needs changes",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/reject")
.send({ reason: "Needs changes" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("allows agent-authored interaction creation and stamps the active run id", async () => {
const app = await createApp({
type: "agent",
@@ -4,8 +4,11 @@ import {
agents,
companies,
createDb,
documentRevisions,
documents,
goals,
heartbeatRuns,
issueDocuments,
instanceSettings,
issueRelations,
issueThreadInteractions,
@@ -37,6 +40,9 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
afterEach(async () => {
await db.delete(issueThreadInteractions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(heartbeatRuns);
await db.delete(issues);
@@ -487,4 +493,255 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
expect(rows).toHaveLength(1);
expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire");
});
it("accepts request_confirmation interactions without creating child issues", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Confirm a request",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Keep editing",
detailsMarkdown: "Creates follow-up work after acceptance.",
},
}, {
userId: "local-board",
});
expect(created.kind).toBe("request_confirmation");
expect(created.status).toBe("pending");
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.createdIssues).toEqual([]);
expect(accepted.interaction).toMatchObject({
kind: "request_confirmation",
status: "accepted",
result: {
version: 1,
outcome: "accepted",
},
resolvedByUserId: "local-board",
});
});
it("expires supersedable request confirmations when a user comments", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Comment supersede",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId,
},
resolvedByUserId: "local-board",
});
});
it("expires request confirmations when the watched issue document revision changes", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const documentId = randomUUID();
const revisionId = randomUUID();
const nextRevisionId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Document target confirmation",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply the plan document?",
target: {
type: "issue_document",
issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
}, {
userId: "local-board",
});
await db.insert(documentRevisions).values({
id: nextRevisionId,
companyId,
documentId,
revisionNumber: 2,
title: "Plan",
format: "markdown",
body: "v2",
});
await db.update(documents).set({
latestBody: "v2",
latestRevisionId: nextRevisionId,
latestRevisionNumber: 2,
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.interaction).toMatchObject({
id: created.id,
status: "expired",
result: {
version: 1,
outcome: "stale_target",
staleTarget: {
type: "issue_document",
key: "plan",
revisionId,
},
},
});
});
});
+131 -4
View File
@@ -205,6 +205,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
source: string;
}) {
if (input.interaction.continuationPolicy !== "wake_assignee") return;
if (input.interaction.kind === "request_confirmation" && input.interaction.status !== "accepted") return;
if (input.interaction.status === "expired") return;
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
@@ -407,6 +409,34 @@ export function issueRoutes(
return value === true || value === "true" || value === "1";
}
async function logExpiredRequestConfirmations(input: {
issue: { id: string; companyId: string; identifier?: string | null };
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
actor: ReturnType<typeof getActorInfo>;
source: string;
}) {
for (const interaction of input.interactions) {
await logActivity(db, {
companyId: input.issue.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
action: "issue.thread_interaction_expired",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier ?? null,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
source: input.source,
result: interaction.result ?? null,
},
});
}
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
@@ -1097,6 +1127,28 @@ export function issueRoutes(
},
});
if (!result.created) {
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: doc.id,
key: doc.key,
latestRevisionId: doc.latestRevisionId,
latestRevisionNumber: doc.latestRevisionNumber,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_updated",
});
}
res.status(result.created ? 201 : 200).json(doc);
});
@@ -1174,6 +1226,26 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: result.document.id,
key: result.document.key,
latestRevisionId: result.document.latestRevisionId,
latestRevisionNumber: result.document.latestRevisionNumber,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_restored",
});
res.json(result.document);
},
);
@@ -1225,6 +1297,25 @@ export function issueRoutes(
}),
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: removed.id,
key: removed.key,
latestRevisionId: null,
latestRevisionNumber: null,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_deleted",
});
res.json({ ok: true });
});
@@ -2088,6 +2179,21 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
issue,
comment,
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.comment",
});
} else if (updateReferenceSummaryAfter) {
issueResponse = {
...issueResponse,
@@ -2569,7 +2675,7 @@ export function issueRoutes(
assertBoard(req);
const actor = getActorInfo(req);
const { interaction, createdIssues } = await issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, req.body, {
const { interaction, createdIssues } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
@@ -2580,7 +2686,9 @@ export function issueRoutes(
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.thread_interaction_accepted",
action: interaction.status === "expired"
? "issue.thread_interaction_expired"
: "issue.thread_interaction_accepted",
entityType: "issue",
entityId: issue.id,
details: {
@@ -2637,7 +2745,7 @@ export function issueRoutes(
assertBoard(req);
const actor = getActorInfo(req);
const interaction = await issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, req.body, {
const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
@@ -2648,7 +2756,9 @@ export function issueRoutes(
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.thread_interaction_rejected",
action: interaction.status === "expired"
? "issue.thread_interaction_expired"
: "issue.thread_interaction_rejected",
entityType: "issue",
entityId: issue.id,
details: {
@@ -2658,6 +2768,8 @@ export function issueRoutes(
rejectionReason:
interaction.kind === "suggest_tasks"
? (interaction.result?.rejectionReason ?? null)
: interaction.kind === "request_confirmation"
? (interaction.result?.reason ?? null)
: null,
},
});
@@ -3023,6 +3135,21 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
currentIssue,
comment,
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue: currentIssue,
interactions: expiredInteractions,
actor,
source: "issue.comment",
});
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
@@ -2,8 +2,10 @@ import { isDeepStrictEqual } from "node:util";
import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
documents,
heartbeatRuns,
issueComments,
issueDocuments,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
@@ -13,6 +15,8 @@ import type {
AskUserQuestionsInteraction,
CreateIssueThreadInteraction,
IssueThreadInteraction,
RequestConfirmationInteraction,
RequestConfirmationTarget,
RejectIssueThreadInteraction,
RespondIssueThreadInteraction,
SuggestTasksInteraction,
@@ -23,6 +27,9 @@ import {
askUserQuestionsPayloadSchema,
askUserQuestionsResultSchema,
createIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
suggestTasksPayloadSchema,
suggestTasksResultSchema,
} from "@paperclipai/shared";
@@ -43,6 +50,11 @@ type CreatedIssueWakeTarget = {
status: string;
};
type ResolvedInteractionResult = {
interaction: IssueThreadInteraction;
createdIssues: CreatedIssueWakeTarget[];
};
type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect;
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
@@ -96,6 +108,13 @@ function hydrateInteraction(
payload: askUserQuestionsPayloadSchema.parse(row.payload),
result: row.result ? askUserQuestionsResultSchema.parse(row.result) : null,
} satisfies AskUserQuestionsInteraction;
case "request_confirmation":
return {
...base,
kind: "request_confirmation",
payload: requestConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestConfirmationInteraction;
default:
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
}
@@ -226,6 +245,110 @@ function normalizeQuestionAnswers(args: {
.filter((answer): answer is AskUserQuestionsAnswer => Boolean(answer));
}
async function getIssueDocumentTargetSnapshot(db: Db | any, args: {
companyId: string;
issueId: string;
target: RequestConfirmationTarget;
}) {
if (args.target.type !== "issue_document") {
throw unprocessable(`Unsupported request confirmation target: ${(args.target as { type: string }).type}`);
}
const targetIssueId = args.target.issueId ?? args.issueId;
const row = await db
.select({
issueId: issueDocuments.issueId,
documentId: issueDocuments.documentId,
key: issueDocuments.key,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(
eq(issueDocuments.companyId, args.companyId),
eq(issueDocuments.issueId, targetIssueId),
eq(issueDocuments.key, args.target.key),
))
.then((rows: Array<{
issueId: string;
documentId: string;
key: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
}>) => rows[0] ?? null);
if (!row) return null;
if (args.target.documentId && args.target.documentId !== row.documentId) return null;
return row;
}
async function assertRequestConfirmationTargetIsCurrent(db: Db | any, args: {
companyId: string;
issueId: string;
target?: RequestConfirmationTarget | null;
}) {
if (!args.target) return;
const snapshot = await getIssueDocumentTargetSnapshot(db, {
companyId: args.companyId,
issueId: args.issueId,
target: args.target,
});
if (!snapshot || snapshot.latestRevisionId !== args.target.revisionId) {
throw unprocessable("request_confirmation target must reference the current issue document revision");
}
if (args.target.revisionNumber && snapshot.latestRevisionNumber !== args.target.revisionNumber) {
throw unprocessable("request_confirmation target revisionNumber must match the current issue document revision");
}
}
async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
row: IssueThreadInteractionRow;
actor: InteractionActor;
}): Promise<IssueThreadInteraction | null> {
if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null;
const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction;
const target = interaction.payload.target ?? null;
if (!target) return null;
const snapshot = await getIssueDocumentTargetSnapshot(db, {
companyId: args.row.companyId,
issueId: args.row.issueId,
target,
});
const isCurrent =
snapshot
&& snapshot.latestRevisionId === target.revisionId
&& (!target.revisionNumber || snapshot.latestRevisionNumber === target.revisionNumber);
if (isCurrent) return null;
const now = new Date();
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: {
version: 1,
outcome: "stale_target",
staleTarget: target,
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, args.row.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (!updated) {
throw conflict("Interaction has already been resolved");
}
await touchIssue(db, args.row.issueId);
return hydrateInteraction(updated);
}
export function issueThreadInteractionService(db: Db) {
async function getIdempotentInteraction(args: {
issueId: string;
@@ -243,6 +366,108 @@ export function issueThreadInteractionService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function getPendingInteractionForResolution(args: {
issue: { id: string; companyId: string };
interactionId: string;
}) {
const current = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, args.interactionId))
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Interaction not found");
if (current.companyId !== args.issue.companyId || current.issueId !== args.issue.id) {
throw notFound("Interaction not found");
}
if (current.status !== "pending") {
throw conflict("Interaction has already been resolved");
}
return current;
}
async function acceptRequestConfirmation(args: {
issue: { id: string; companyId: string };
current: IssueThreadInteractionRow;
actor: InteractionActor;
}): Promise<IssueThreadInteraction> {
const expired = await expireStaleRequestConfirmationTarget(db, {
row: args.current,
actor: args.actor,
});
if (expired) {
return expired;
}
const now = new Date();
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "accepted",
result: {
version: 1,
outcome: "accepted",
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, args.current.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (!updated) {
throw conflict("Interaction has already been resolved");
}
await touchIssue(db, args.issue.id);
return hydrateInteraction(updated);
}
async function rejectRequestConfirmation(args: {
issue: { id: string; companyId: string };
current: IssueThreadInteractionRow;
input: RejectIssueThreadInteraction;
actor: InteractionActor;
}): Promise<IssueThreadInteraction> {
const expired = await expireStaleRequestConfirmationTarget(db, {
row: args.current,
actor: args.actor,
});
if (expired) {
return expired;
}
const now = new Date();
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "rejected",
result: {
version: 1,
outcome: "rejected",
reason: args.input.reason?.trim() || null,
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, args.current.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (!updated) {
throw conflict("Interaction has already been resolved");
}
await touchIssue(db, args.issue.id);
return hydrateInteraction(updated);
}
return {
listForIssue: async (issueId: string) => {
const rows = await db
@@ -314,6 +539,14 @@ export function issueThreadInteractionService(db: Db) {
}
}
if (data.kind === "request_confirmation") {
await assertRequestConfirmationTargetIsCurrent(db, {
companyId: issue.companyId,
issueId: issue.id,
target: data.payload.target ?? null,
});
}
let created: IssueThreadInteractionRow;
try {
[created] = await db
@@ -356,6 +589,31 @@ export function issueThreadInteractionService(db: Db) {
return hydrateInteraction(created);
},
acceptInteraction: async (
issue: { id: string; companyId: string; projectId: string | null; goalId: string | null },
interactionId: string,
input: AcceptIssueThreadInteraction,
actor: InteractionActor,
): Promise<ResolvedInteractionResult> => {
const data = acceptIssueThreadInteractionSchema.parse(input);
const current = await getPendingInteractionForResolution({ issue, interactionId });
switch (current.kind) {
case "suggest_tasks":
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
case "request_confirmation":
return {
interaction: await acceptRequestConfirmation({
issue,
current,
actor,
}),
createdIssues: [],
};
default:
throw unprocessable(`Interactions of kind ${current.kind} cannot be accepted`);
}
},
acceptSuggestedTasks: async (
issue: { id: string; companyId: string; projectId: string | null; goalId: string | null },
interactionId: string,
@@ -487,6 +745,29 @@ export function issueThreadInteractionService(db: Db) {
};
},
rejectInteraction: async (
issue: { id: string; companyId: string },
interactionId: string,
input: RejectIssueThreadInteraction,
actor: InteractionActor,
) => {
const data = rejectIssueThreadInteractionSchema.parse(input);
const current = await getPendingInteractionForResolution({ issue, interactionId });
switch (current.kind) {
case "suggest_tasks":
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor);
case "request_confirmation":
return rejectRequestConfirmation({
issue,
current,
input: data,
actor,
});
default:
throw unprocessable(`Interactions of kind ${current.kind} cannot be rejected`);
}
},
rejectSuggestedTasks: async (
issue: { id: string; companyId: string },
interactionId: string,
@@ -530,6 +811,125 @@ export function issueThreadInteractionService(db: Db) {
return hydrateInteraction(updated);
},
expireRequestConfirmationsSupersededByComment: async (
issue: { id: string; companyId: string },
comment: { id: string; authorUserId?: string | null },
actor: InteractionActor,
) => {
if (!comment.authorUserId) return [];
const rows = await db
.select()
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "pending"),
));
const superseded = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
return interaction.payload.supersedeOnUserComment === true;
});
if (superseded.length === 0) return [];
const now = new Date();
const expired: IssueThreadInteraction[] = [];
for (const row of superseded) {
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: comment.id,
},
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, row.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (updated) expired.push(hydrateInteraction(updated));
}
if (expired.length > 0) {
await touchIssue(db, issue.id);
}
return expired;
},
expireStaleRequestConfirmationsForIssueDocument: async (
issue: { id: string; companyId: string },
document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null,
actor: InteractionActor,
) => {
const rows = await db
.select()
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "pending"),
));
const staleRows = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const target = interaction.payload.target;
if (!target || target.type !== "issue_document") return false;
const targetIssueId = target.issueId ?? issue.id;
if (targetIssueId !== issue.id) return false;
if (document && target.documentId && target.documentId !== document.id) return false;
if (document && target.key !== document.key) return false;
if (!document) return true;
return (
target.revisionId !== document.latestRevisionId
|| (target.revisionNumber != null && target.revisionNumber !== document.latestRevisionNumber)
);
});
if (staleRows.length === 0) return [];
const now = new Date();
const expired: IssueThreadInteraction[] = [];
for (const row of staleRows) {
const target = (hydrateInteraction(row) as RequestConfirmationInteraction).payload.target ?? null;
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: {
version: 1,
outcome: "stale_target",
staleTarget: target,
},
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, row.id),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (updated) expired.push(hydrateInteraction(updated));
}
if (expired.length > 0) {
await touchIssue(db, issue.id);
}
return expired;
},
answerQuestions: async (
issue: { id: string; companyId: string },
interactionId: string,