Expire confirmations after user comments

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-19 14:25:05 -05:00
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();
+11 -1
View File
@@ -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,