From 70cc02333b89502bd7e4aba41df9423c6d624602 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 21 Apr 2026 17:30:52 -0500 Subject: [PATCH] Return accepted confirmations to creator agents --- .../issue-thread-interaction-routes.test.ts | 74 +++++++++ .../issue-thread-interactions-service.test.ts | 82 ++++++++++ server/src/routes/issues.ts | 31 +++- .../src/services/issue-thread-interactions.ts | 150 ++++++++++++++---- 4 files changed, 301 insertions(+), 36 deletions(-) diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 8db09e587da..77c45a05056 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -407,6 +407,80 @@ describe("issue thread interaction routes", () => { ); }); + it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "local-board", + })); + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-4", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-4", + payload: { + version: 1, + prompt: "Approve 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: [], + continuationIssue: { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + assigneeAgentId: CREATED_AGENT_ID, + assigneeUserId: null, + status: "todo", + }, + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-4/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + CREATED_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-4", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + source: "request_confirmation_accept", + assigneeAgentId: CREATED_AGENT_ID, + assigneeUserId: null, + _previous: expect.objectContaining({ + assigneeUserId: "local-board", + }), + }), + }), + ); + }); + it("does not emit a continuation wake when request confirmations are rejected", async () => { mockInteractionService.rejectInteraction.mockResolvedValueOnce({ id: "interaction-3", diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index a3290b6a7ae..527f81941f8 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -584,6 +584,88 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { })).rejects.toThrow("A decline reason is required for this confirmation"); }); + it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const agentId = 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(agents).values({ + id: agentId, + companyId, + name: "Senior Product Engineer", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Review the plan", + status: "in_review", + priority: "medium", + assigneeUserId: "local-board", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee_on_accept", + payload: { + version: 1, + prompt: "Approve this plan?", + acceptLabel: "Approve plan", + rejectLabel: "Ask for changes", + }, + }, { + agentId, + }); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.continuationIssue).toEqual({ + id: issueId, + assigneeAgentId: agentId, + assigneeUserId: null, + status: "todo", + }); + + const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId); + expect(updatedIssue).toMatchObject({ + id: issueId, + status: "todo", + assigneeAgentId: agentId, + assigneeUserId: null, + }); + }); + it("expires supersedable request confirmations when a user comments", async () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index dbd7c8b69c9..d75bd343fea 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2678,10 +2678,11 @@ export function issueRoutes( assertBoard(req); const actor = getActorInfo(req); - const { interaction, createdIssues } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, { + const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); + const continuationWakeIssue = continuationIssue ?? issue; await logActivity(db, { companyId: issue.companyId, @@ -2709,6 +2710,32 @@ export function issueRoutes( }, }); + if (continuationIssue) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + status: continuationIssue.status, + assigneeAgentId: continuationIssue.assigneeAgentId ?? null, + assigneeUserId: continuationIssue.assigneeUserId ?? null, + source: "request_confirmation_accept", + interactionId: interaction.id, + _previous: { + status: issue.status, + assigneeAgentId: issue.assigneeAgentId ?? null, + assigneeUserId: issue.assigneeUserId ?? null, + }, + }, + }); + } + for (const createdIssue of createdIssues) { void queueIssueAssignmentWakeup({ heartbeat, @@ -2723,7 +2750,7 @@ export function issueRoutes( queueResolvedInteractionContinuationWakeup({ heartbeat, - issue, + issue: continuationWakeIssue, interaction, actor, source: "issue.interaction.accept", diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 86a05a671e8..4175e3f361d 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -44,19 +44,29 @@ type InteractionActor = { const ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT = "issue_thread_interactions_company_issue_idempotency_uq"; -type CreatedIssueWakeTarget = { +type IssueWakeTarget = { id: string; assigneeAgentId: string | null; + assigneeUserId?: string | null; status: string; }; type ResolvedInteractionResult = { interaction: IssueThreadInteraction; - createdIssues: CreatedIssueWakeTarget[]; + createdIssues: IssueWakeTarget[]; + continuationIssue?: IssueWakeTarget | null; }; type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect; +type IssueResolutionContext = { + id: string; + companyId: string; + status: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; +}; + function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { if (typeof error !== "object" || error === null) return false; const err = error as { code?: string; constraint?: string; constraint_name?: string }; @@ -127,6 +137,24 @@ async function touchIssue(db: Db | any, issueId: string) { .where(eq(issues.id, issueId)); } +function isTerminalIssueStatus(status: string) { + return status === "done" || status === "cancelled"; +} + +function shouldReturnAcceptedConfirmationToCreatorAgent(args: { + issue: IssueResolutionContext; + current: IssueThreadInteractionRow; + actor: InteractionActor; +}) { + if (args.current.kind !== "request_confirmation") return false; + if (!args.current.createdByAgentId) return false; + if (!args.actor.userId) return false; + if (!args.issue.assigneeUserId) return false; + if (args.issue.assigneeAgentId) return false; + if (isTerminalIssueStatus(args.issue.status)) return false; + return true; +} + function buildTaskCreationOrder(tasks: ReadonlyArray) { const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const)); const ordered: Array = []; @@ -436,40 +464,91 @@ export function issueThreadInteractionService(db: Db) { issue: { id: string; companyId: string }; current: IssueThreadInteractionRow; actor: InteractionActor; - }): Promise { + }): Promise<{ + interaction: IssueThreadInteraction; + continuationIssue: IssueWakeTarget | null; + }> { const expired = await expireStaleRequestConfirmationTarget(db, { row: args.current, actor: args.actor, }); if (expired) { - return expired; + return { interaction: expired, continuationIssue: null }; } 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(); + return db.transaction(async (tx) => { + const [updated] = await tx + .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); + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + const issueContext = await tx + .select({ + id: issues.id, + companyId: issues.companyId, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issues) + .where(eq(issues.id, args.issue.id)) + .then((rows: IssueResolutionContext[]) => rows[0] ?? null); + + if (!issueContext || issueContext.companyId !== args.issue.companyId) { + throw notFound("Issue not found"); + } + + let continuationIssue: IssueWakeTarget | null = null; + if (shouldReturnAcceptedConfirmationToCreatorAgent({ + issue: issueContext, + current: args.current, + actor: args.actor, + })) { + const returnStatus = issueContext.status === "blocked" ? "blocked" : "todo"; + const returnedIssue = await issueService(db).update(args.issue.id, { + status: returnStatus, + assigneeAgentId: args.current.createdByAgentId, + assigneeUserId: null, + actorAgentId: args.actor.agentId ?? null, + actorUserId: args.actor.userId ?? null, + }, tx); + + if (returnedIssue) { + continuationIssue = { + id: returnedIssue.id, + assigneeAgentId: returnedIssue.assigneeAgentId ?? null, + assigneeUserId: returnedIssue.assigneeUserId ?? null, + status: returnedIssue.status, + }; + } + } else { + await touchIssue(tx, args.issue.id); + } + + return { + interaction: hydrateInteraction(updated), + continuationIssue, + }; + }); } async function rejectRequestConfirmation(args: { @@ -652,15 +731,18 @@ export function issueThreadInteractionService(db: Db) { switch (current.kind) { case "suggest_tasks": return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor); - case "request_confirmation": + case "request_confirmation": { + const accepted = await acceptRequestConfirmation({ + issue, + current, + actor, + }); return { - interaction: await acceptRequestConfirmation({ - issue, - current, - actor, - }), + interaction: accepted.interaction, + continuationIssue: accepted.continuationIssue, createdIssues: [], }; + } default: throw unprocessable(`Interactions of kind ${current.kind} cannot be accepted`); } @@ -720,7 +802,7 @@ export function issueThreadInteractionService(db: Db) { const parentById = new Map(parentRows.map((row) => [row.id, row] as const)); const createdByClientKey = new Map(); - const createdWakeTargets: CreatedIssueWakeTarget[] = []; + const createdWakeTargets: IssueWakeTarget[] = []; await db.transaction(async (tx) => { for (const task of orderedTasks) {