mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Return accepted confirmations to creator agents
This commit is contained in:
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user