PAP-2729 Handle interaction waiting lifecycle

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 18:13:05 -05:00
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();
+54 -1
View File
@@ -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);
},