mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Expire confirmations after user comments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cbe2b55561
commit
3983d3fdfc
4 files changed
+311
-31
No files matched your search
@@ -16,6 +16,7 @@ const mockInteractionService = vi.hoisted(() => ({
|
||||
acceptSuggestedTasks: vi.fn(),
|
||||
rejectInteraction: vi.fn(),
|
||||
rejectSuggestedTasks: vi.fn(),
|
||||
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
|
||||
answerQuestions: vi.fn(),
|
||||
cancelQuestions: vi.fn(),
|
||||
}));
|
||||
@@ -156,6 +157,7 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(createIssue());
|
||||
mockInteractionService.listForIssue.mockResolvedValue([]);
|
||||
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]);
|
||||
mockInteractionService.create.mockResolvedValue({
|
||||
id: "interaction-1",
|
||||
companyId: "company-1",
|
||||
@@ -288,6 +290,18 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
});
|
||||
|
||||
it("lists and creates board-authored interactions", async () => {
|
||||
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([
|
||||
{
|
||||
id: "interaction-expired",
|
||||
kind: "request_confirmation",
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockInteractionService.listForIssue.mockResolvedValue([
|
||||
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
|
||||
]);
|
||||
@@ -298,6 +312,24 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
expect(listRes.body).toEqual([
|
||||
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
|
||||
]);
|
||||
expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.thread_interaction_expired",
|
||||
details: expect.objectContaining({
|
||||
interactionId: "interaction-expired",
|
||||
interactionKind: "request_confirmation",
|
||||
source: "issue.interactions.catchup_superseded_by_comment",
|
||||
result: expect.objectContaining({
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
documents,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
instanceSettings,
|
||||
issueRelations,
|
||||
@@ -41,6 +42,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
@@ -57,6 +59,37 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedConfirmationIssue(title = "Comment supersede") {
|
||||
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,
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
return { companyId, goalId, issueId };
|
||||
}
|
||||
|
||||
it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
@@ -783,35 +816,10 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("expires supersedable request confirmations when a user comments", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
it("expires request confirmations by default when a user comments after creation", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue();
|
||||
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,
|
||||
@@ -820,7 +828,6 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Proceed with the current draft?",
|
||||
supersedeOnUserComment: true,
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
@@ -831,6 +838,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
companyId,
|
||||
}, {
|
||||
id: commentId,
|
||||
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
||||
authorUserId: "local-board",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
@@ -849,6 +857,150 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps request confirmations pending when user-comment supersede is explicitly disabled", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out");
|
||||
|
||||
await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Proceed with the current draft?",
|
||||
supersedeOnUserComment: false,
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(Date.now() + 1_000),
|
||||
authorUserId: "local-board",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(expired).toHaveLength(0);
|
||||
const rows = await db.select().from(issueThreadInteractions);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("does not supersede request confirmations for agent, system, or older user comments", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Proceed with the current draft?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
const createdAtMs = new Date(created.createdAt).getTime();
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(createdAtMs + 1_000),
|
||||
authorUserId: null,
|
||||
}, {
|
||||
agentId: randomUUID(),
|
||||
})).resolves.toHaveLength(0);
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(createdAtMs + 1_000),
|
||||
authorUserId: null,
|
||||
}, {})).resolves.toHaveLength(0);
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(createdAtMs - 1_000),
|
||||
authorUserId: "local-board",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).resolves.toHaveLength(0);
|
||||
|
||||
const rows = await db.select().from(issueThreadInteractions);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("repairs historical request confirmations superseded by later user comments idempotently", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Historical comment supersede");
|
||||
const commentId = randomUUID();
|
||||
const createdAt = new Date("2026-05-18T12:00:00.000Z");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Proceed with the current draft?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ createdAt, updatedAt: createdAt })
|
||||
.where(eq(issueThreadInteractions.id, created.id));
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "local-board",
|
||||
authorType: "user",
|
||||
body: "Please revise this first.",
|
||||
createdAt: new Date("2026-05-18T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-05-18T12:01:00.000Z"),
|
||||
});
|
||||
|
||||
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
||||
id: issueId,
|
||||
companyId,
|
||||
});
|
||||
|
||||
expect(expired).toHaveLength(1);
|
||||
expect(expired[0]).toMatchObject({
|
||||
id: created.id,
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId,
|
||||
},
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: "local-board",
|
||||
});
|
||||
|
||||
await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
|
||||
id: issueId,
|
||||
companyId,
|
||||
})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("expires request confirmations when the watched issue document revision changes", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
|
||||
@@ -4568,7 +4568,17 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const interactions = await issueThreadInteractionService(db).listForIssue(id);
|
||||
const actor = getActorInfo(req);
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const expiredInteractions = await interactionSvc.expireRequestConfirmationsSupersededByHistoricalComments(issue);
|
||||
await logExpiredRequestConfirmations({
|
||||
issue,
|
||||
interactions: expiredInteractions,
|
||||
actor,
|
||||
source: "issue.interactions.catchup_superseded_by_comment",
|
||||
});
|
||||
|
||||
const interactions = await interactionSvc.listForIssue(id);
|
||||
res.json(interactions);
|
||||
});
|
||||
|
||||
|
||||
@@ -158,6 +158,20 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) {
|
||||
return interaction.payload.supersedeOnUserComment !== false;
|
||||
}
|
||||
|
||||
function isCommentAtOrAfterInteraction(args: {
|
||||
commentCreatedAt: Date | string;
|
||||
interactionCreatedAt: Date | string;
|
||||
}) {
|
||||
const commentCreatedAtMs = new Date(args.commentCreatedAt).getTime();
|
||||
const interactionCreatedAtMs = new Date(args.interactionCreatedAt).getTime();
|
||||
if (!Number.isFinite(commentCreatedAtMs) || !Number.isFinite(interactionCreatedAtMs)) return false;
|
||||
return commentCreatedAtMs >= interactionCreatedAtMs;
|
||||
}
|
||||
|
||||
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]> = [];
|
||||
@@ -967,7 +981,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
|
||||
expireRequestConfirmationsSupersededByComment: async (
|
||||
issue: { id: string; companyId: string },
|
||||
comment: { id: string; authorUserId?: string | null },
|
||||
comment: { id: string; createdAt: Date | string; authorUserId?: string | null },
|
||||
actor: InteractionActor,
|
||||
) => {
|
||||
if (!comment.authorUserId) return [];
|
||||
@@ -984,7 +998,13 @@ export function issueThreadInteractionService(db: Db) {
|
||||
|
||||
const superseded = rows.filter((row) => {
|
||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
||||
return interaction.payload.supersedeOnUserComment === true;
|
||||
return (
|
||||
shouldSupersedeRequestConfirmationOnUserComment(interaction)
|
||||
&& isCommentAtOrAfterInteraction({
|
||||
commentCreatedAt: comment.createdAt,
|
||||
interactionCreatedAt: row.createdAt,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (superseded.length === 0) return [];
|
||||
@@ -1020,6 +1040,72 @@ export function issueThreadInteractionService(db: Db) {
|
||||
return expired;
|
||||
},
|
||||
|
||||
expireRequestConfirmationsSupersededByHistoricalComments: async (
|
||||
issue: { id: string; companyId: string },
|
||||
) => {
|
||||
const [rows, comments] = await Promise.all([
|
||||
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"),
|
||||
)),
|
||||
db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.companyId, issue.companyId),
|
||||
eq(issueComments.issueId, issue.id),
|
||||
))
|
||||
.orderBy(asc(issueComments.createdAt)),
|
||||
]);
|
||||
|
||||
const userComments = comments.filter((comment) => comment.authorUserId);
|
||||
if (rows.length === 0 || userComments.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
for (const row of rows) {
|
||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
||||
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
|
||||
|
||||
const supersedingComment = userComments.find((comment) => isCommentAtOrAfterInteraction({
|
||||
commentCreatedAt: comment.createdAt,
|
||||
interactionCreatedAt: row.createdAt,
|
||||
}));
|
||||
if (!supersedingComment) continue;
|
||||
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: supersedingComment.id,
|
||||
},
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: supersedingComment.authorUserId,
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user