Return accepted confirmations to creator agents

This commit is contained in:
Dotta committed 2026-04-21 17:34:39 -05:00
1 parent 27b8958368
commit 70cc02333b
4 files changed
+301 -36

No files matched your search

@@ -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",
@@ -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();
+29 -2
View File
@@ -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",
+116 -34
View File
@@ -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<SuggestTasksInteraction["payload"]["tasks"][number]>) {
const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const));
const ordered: Array<SuggestTasksInteraction["payload"]["tasks"][number]> = [];
@@ -436,40 +464,91 @@ export function issueThreadInteractionService(db: Db) {
issue: { id: string; companyId: string };
current: IssueThreadInteractionRow;
actor: InteractionActor;
}): Promise<IssueThreadInteraction> {
}): 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<string, SuggestTasksResultCreatedTask>();
const createdWakeTargets: CreatedIssueWakeTarget[] = [];
const createdWakeTargets: IssueWakeTarget[] = [];
await db.transaction(async (tx) => {
for (const task of orderedTasks) {