mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
PAP-2729 Handle interaction waiting lifecycle
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1e68cb9be0
commit
c1fd7521a8
4 files changed
+400
-1
No files matched your search
@@ -48,10 +48,13 @@ function registerModuleMocks() {
|
||||
})),
|
||||
}),
|
||||
clampIssueListLimit: (value: number) => value,
|
||||
companyService: () => ({}),
|
||||
ISSUE_LIST_DEFAULT_LIMIT: 500,
|
||||
ISSUE_LIST_MAX_LIMIT: 1000,
|
||||
documentService: () => ({}),
|
||||
environmentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
executionWorkspaceServiceDirect: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
@@ -515,6 +518,108 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wakes the current assignee when a rejected confirmation uses wake-assignee continuation", async () => {
|
||||
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
||||
id: "interaction-7",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "request_confirmation",
|
||||
status: "rejected",
|
||||
continuationPolicy: "wake_assignee",
|
||||
idempotencyKey: null,
|
||||
sourceCommentId: null,
|
||||
sourceRunId: "run-7",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this plan?",
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: "Needs changes",
|
||||
},
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-20T12:05:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:05:00.000Z",
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-7/reject")
|
||||
.send({ reason: "Needs changes" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
interactionId: "interaction-7",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "rejected",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes the current assignee when an accepted confirmation expires because its target is stale", async () => {
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
interaction: {
|
||||
id: "interaction-6",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "request_confirmation",
|
||||
status: "expired",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
idempotencyKey: null,
|
||||
sourceCommentId: null,
|
||||
sourceRunId: "run-6",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "old-revision",
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "old-revision",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-20T12:05:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:05:00.000Z",
|
||||
},
|
||||
createdIssues: [],
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-6/accept")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
interactionId: "interaction-6",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "expired",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit an accept-only continuation wake for rejected suggested tasks", async () => {
|
||||
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
||||
id: "interaction-5",
|
||||
|
||||
@@ -689,6 +689,206 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("moves agent-authored plan confirmations into review while preserving the return assignee", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const documentId = randomUUID();
|
||||
const revisionId = 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 plan",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Plan the implementation",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "v1",
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: revisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "v1",
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
idempotencyKey: `confirmation:${issueId}:plan:${revisionId}`,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
revisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
agentId,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
});
|
||||
|
||||
const [reviewIssue] = await db.select().from(issues);
|
||||
expect(reviewIssue).toMatchObject({
|
||||
id: issueId,
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
|
||||
const idempotent = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
idempotencyKey: `confirmation:${issueId}:plan:${revisionId}`,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
revisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
agentId,
|
||||
});
|
||||
|
||||
expect(idempotent.id).toBe(created.id);
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("moves agent-authored questions into review while preserving the return assignee", 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: "Ask a question",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Clarify the implementation",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "ask_user_questions",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [{
|
||||
id: "scope",
|
||||
prompt: "Which scope?",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "minimal", label: "Minimal" }],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
agentId,
|
||||
});
|
||||
|
||||
const [reviewIssue] = await db.select().from(issues);
|
||||
expect(reviewIssue).toMatchObject({
|
||||
id: issueId,
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("expires supersedable request confirmations when a user comments", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
|
||||
@@ -204,6 +204,13 @@ function isExplicitResumeCapableStatus(status: string | null | undefined) {
|
||||
return status === "done" || status === "blocked" || status === "todo" || status === "in_progress";
|
||||
}
|
||||
|
||||
function getRequestConfirmationResolutionOutcome(interaction: { kind: string; result?: unknown }) {
|
||||
if (interaction.kind !== "request_confirmation") return null;
|
||||
if (typeof interaction.result !== "object" || interaction.result === null) return null;
|
||||
const outcome = (interaction.result as { outcome?: unknown }).outcome;
|
||||
return typeof outcome === "string" ? outcome : null;
|
||||
}
|
||||
|
||||
function queueResolvedInteractionContinuationWakeup(input: {
|
||||
heartbeat: ReturnType<typeof heartbeatService>;
|
||||
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||
@@ -214,6 +221,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
continuationPolicy: string;
|
||||
sourceCommentId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
result?: unknown;
|
||||
};
|
||||
actor: { actorType: "user" | "agent"; actorId: string };
|
||||
source: string;
|
||||
@@ -225,8 +233,18 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
if (
|
||||
input.interaction.continuationPolicy === "wake_assignee_on_accept"
|
||||
&& input.interaction.status !== "accepted"
|
||||
&& !(input.interaction.status === "expired" && getRequestConfirmationResolutionOutcome(input.interaction) === "stale_target")
|
||||
) return;
|
||||
if (
|
||||
input.interaction.status === "expired"
|
||||
&& !(
|
||||
getRequestConfirmationResolutionOutcome(input.interaction) === "stale_target"
|
||||
|| (
|
||||
input.interaction.continuationPolicy === "wake_assignee"
|
||||
&& getRequestConfirmationResolutionOutcome(input.interaction) === "superseded_by_comment"
|
||||
)
|
||||
)
|
||||
) return;
|
||||
if (input.interaction.status === "expired") return;
|
||||
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
|
||||
|
||||
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
|
||||
@@ -2976,6 +2994,41 @@ export function issueRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
const refreshedIssue = await svc.getById(id);
|
||||
if (
|
||||
refreshedIssue
|
||||
&& (
|
||||
refreshedIssue.status !== issue.status
|
||||
|| refreshedIssue.assigneeAgentId !== issue.assigneeAgentId
|
||||
|| refreshedIssue.assigneeUserId !== issue.assigneeUserId
|
||||
)
|
||||
) {
|
||||
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: refreshedIssue.status,
|
||||
assigneeAgentId: refreshedIssue.assigneeAgentId ?? null,
|
||||
assigneeUserId: refreshedIssue.assigneeUserId ?? null,
|
||||
source: "issue.thread_interaction_created",
|
||||
interactionId: interaction.id,
|
||||
interactionKind: interaction.kind,
|
||||
_previous: {
|
||||
status: issue.status,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(interaction);
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,45 @@ type IssueResolutionContext = {
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
function isAgentAuthoredWaitingInteraction(input: CreateIssueThreadInteraction, actor: InteractionActor) {
|
||||
return Boolean(actor.agentId) && (input.kind === "request_confirmation" || input.kind === "ask_user_questions");
|
||||
}
|
||||
|
||||
async function moveIssueToReviewForAgentWaitingInteraction(
|
||||
db: Db,
|
||||
issue: { id: string; companyId: string },
|
||||
input: CreateIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
) {
|
||||
if (!actor.agentId || !isAgentAuthoredWaitingInteraction(input, actor)) return null;
|
||||
|
||||
const issueContext = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issue.id))
|
||||
.then((rows: IssueResolutionContext[]) => rows[0] ?? null);
|
||||
|
||||
if (!issueContext || issueContext.companyId !== issue.companyId) {
|
||||
throw notFound("Issue not found");
|
||||
}
|
||||
if (issueContext.status !== "todo" && issueContext.status !== "in_progress") return null;
|
||||
if (issueContext.assigneeUserId) return null;
|
||||
|
||||
return issueService(db).update(issue.id, {
|
||||
status: "in_review",
|
||||
assigneeAgentId: issueContext.assigneeAgentId ?? actor.agentId,
|
||||
assigneeUserId: null,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.userId ?? 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 };
|
||||
@@ -640,6 +679,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
});
|
||||
}
|
||||
await moveIssueToReviewForAgentWaitingInteraction(db, issue, data, actor);
|
||||
return hydrateInteraction(existing);
|
||||
}
|
||||
}
|
||||
@@ -717,6 +757,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
return hydrateInteraction(existing);
|
||||
}
|
||||
|
||||
await moveIssueToReviewForAgentWaitingInteraction(db, issue, data, actor);
|
||||
await touchIssue(db, issue.id);
|
||||
return hydrateInteraction(created);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user