diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 666f6a46e48..7f34a7a5fa0 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -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", diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index ac418d34b3b..6146f88c0b3 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -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(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5b9bb6410f8..5e2f5ef8243 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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; 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); }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index a139c92044c..ac26df38f19 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -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); },