From 3983d3fdfc1717dfa1441cdadad8bf19f1d5f436 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 18 May 2026 15:07:55 -0500 Subject: [PATCH] Expire confirmations after user comments Co-Authored-By: Paperclip --- .../issue-thread-interaction-routes.test.ts | 32 +++ .../issue-thread-interactions-service.test.ts | 208 +++++++++++++++--- server/src/routes/issues.ts | 12 +- .../src/services/issue-thread-interactions.ts | 90 +++++++- 4 files changed, 311 insertions(+), 31 deletions(-) diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index ae5df02304e..818c5d7d9ac 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -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") diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index f1f0be38d66..5251f1fdb38 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -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(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e1be1a149ba..bc09335d96a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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); }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 80b31c1120c..98d9f153241 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -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) { const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const)); const ordered: Array = []; @@ -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,