import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), assertCheckoutOwner: vi.fn(), update: vi.fn(), addComment: vi.fn(), findMentionedAgents: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), })); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), getRun: vi.fn(async () => null), getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), })); const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), resolveByReference: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockTxInsertValues = vi.hoisted(() => vi.fn(async () => undefined)); const mockTxInsert = vi.hoisted(() => vi.fn(() => ({ values: mockTxInsertValues }))); const mockTx = vi.hoisted(() => ({ insert: mockTxInsert, })); const mockDb = vi.hoisted(() => ({ transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx)), })); const mockFeedbackService = vi.hoisted(() => ({ listIssueVotesForUser: vi.fn(async () => []), saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), })); const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(async () => ({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, })), listCompanyIds: vi.fn(async () => ["company-1"]), })); const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); vi.mock("@paperclipai/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), trackErrorHandlerCrash: vi.fn(), })); vi.mock("../telemetry.js", () => ({ getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, documentService: () => ({}), executionWorkspaceService: () => ({}), feedbackService: () => mockFeedbackService, goalService: () => ({}), heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], }), emptySummary: () => ({ outbound: [], inbound: [] }), listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), syncComment: async () => undefined, syncDocument: async () => undefined, syncIssue: async () => undefined, }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, workProductService: () => ({}), })); function registerModuleMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), trackErrorHandlerCrash: vi.fn(), })); vi.doMock("../telemetry.js", () => ({ getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, documentService: () => ({}), executionWorkspaceService: () => ({}), feedbackService: () => mockFeedbackService, goalService: () => ({}), heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], }), emptySummary: () => ({ outbound: [], inbound: [] }), listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), syncComment: async () => undefined, syncDocument: async () => undefined, syncIssue: async () => undefined, }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, workProductService: () => ({}), })); } function createApp() { const app = express(); app.use(express.json()); return app; } async function installActor(app: express.Express, actor?: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), vi.importActual("../middleware/index.js"), ]); app.use((req, _res, next) => { (req as any).actor = actor ?? { type: "board", userId: "local-board", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, }; next(); }); app.use("/api", issueRoutes(mockDb as any, {} as any)); app.use(errorHandler); return app; } async function normalizePolicy(input: { stages: Array<{ id: string; type: "review" | "approval"; participants: Array<{ type: "agent"; agentId: string } | { type: "user"; userId: string }>; }>; }) { const { normalizeIssueExecutionPolicy } = await import("../services/issue-execution-policy.js"); return normalizeIssueExecutionPolicy(input); } function makeIssue(status: "todo" | "done" | "blocked") { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", status, assigneeAgentId: "22222222-2222-4222-8222-222222222222", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-580", title: "Comment reopen default", }; } describe("issue comment reopen routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockReset(); mockIssueService.assertCheckoutOwner.mockReset(); mockIssueService.update.mockReset(); mockIssueService.addComment.mockReset(); mockIssueService.findMentionedAgents.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); mockAccessService.canUser.mockReset(); mockAccessService.hasPermission.mockReset(); mockHeartbeatService.wakeup.mockReset(); mockHeartbeatService.reportRunActivity.mockReset(); mockHeartbeatService.getRun.mockReset(); mockHeartbeatService.getActiveRunForAgent.mockReset(); mockHeartbeatService.cancelRun.mockReset(); mockAgentService.getById.mockReset(); mockAgentService.resolveByReference.mockReset(); mockLogActivity.mockReset(); mockFeedbackService.listIssueVotesForUser.mockReset(); mockFeedbackService.saveIssueVote.mockReset(); mockInstanceSettingsService.get.mockReset(); mockInstanceSettingsService.listCompanyIds.mockReset(); mockRoutineService.syncRunStatusForIssue.mockReset(); mockTxInsertValues.mockReset(); mockTxInsert.mockReset(); mockDb.transaction.mockReset(); mockTxInsertValues.mockResolvedValue(undefined); mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues })); mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx)); mockHeartbeatService.wakeup.mockResolvedValue(undefined); mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); mockHeartbeatService.getRun.mockResolvedValue(null); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); mockHeartbeatService.cancelRun.mockResolvedValue(null); mockLogActivity.mockResolvedValue(undefined); mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); mockFeedbackService.saveIssueVote.mockResolvedValue({ vote: null, consentEnabledNow: false, sharingEnabled: false, }); mockInstanceSettingsService.get.mockResolvedValue({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, }); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); mockIssueService.addComment.mockResolvedValue({ id: "comment-1", issueId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", body: "hello", createdAt: new Date(), updatedAt: new Date(), authorAgentId: null, authorUserId: "local-board", }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockAccessService.canUser.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockResolvedValue(null); mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({ ambiguous: false, agent: { id: reference, }, })); }); it("treats reopen=true as a no-op when the issue is already open", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue("todo"), ...patch, })); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); expect(res.status).toBe(200); expect(res.body.assigneeAgentId).toBe("33333333-3333-4333-8333-333333333333"); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", details: expect.not.objectContaining({ reopened: true }), }), ); }); it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue("done"), ...patch, })); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ assigneeAgentId: "33333333-3333-4333-8333-333333333333", status: "todo", actorAgentId: null, actorUserId: "local-board", }), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", details: expect.objectContaining({ reopened: true, reopenedFrom: "done", status: "todo", }), }), ); }); it("resolves assignee shortnames before updating an issue", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue("todo"), ...patch, })); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: { id: "33333333-3333-4333-8333-333333333333" }, }); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", assigneeAgentId: "codexcoder" }); expect(res.status).toBe(200); expect(mockAgentService.resolveByReference).toHaveBeenCalledWith("company-1", "codexcoder"); expect(mockIssueService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ assigneeAgentId: "33333333-3333-4333-8333-333333333333", }), ); }); it("rejects ambiguous assignee shortnames", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: true, agent: null, }); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ assigneeAgentId: "codexcoder" }); expect(res.status).toBe(409); expect(res.body.error).toContain("ambiguous"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("rejects missing assignee shortnames", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: null, }); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ assigneeAgentId: "codexcoder" }); expect(res.status).toBe(404); expect(res.body.error).toBe("Agent not found"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("reopens closed issues via the PATCH comment path", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue("done"), ...patch, })); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ assigneeAgentId: "33333333-3333-4333-8333-333333333333", status: "todo", actorAgentId: null, actorUserId: "local-board", }), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", details: expect.objectContaining({ reopened: true, reopenedFrom: "done", status: "todo", }), }), ); }); it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue("done"), ...patch, })); const res = await request(await installActor(createApp())) .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") .send({ body: "hello" }); expect(res.status).toBe(201); expect(mockIssueService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", { status: "todo" }, ); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "issue_reopened_via_comment", payload: expect.objectContaining({ reopenedFrom: "done", }), }), ); }); it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { mockIssueService.getById.mockResolvedValue({ ...makeIssue("done"), assigneeAgentId: null, assigneeUserId: "local-board", }); const res = await request(await installActor(createApp())) .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") .send({ body: "hello" }); expect(res.status).toBe(201); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { const issue = makeIssue("blocked"); mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ status: "todo" }); expect(res.status).toBe(200); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ source: "automation", triggerDetail: "system", reason: "issue_status_changed", payload: expect.objectContaining({ issueId: "11111111-1111-4111-8111-111111111111", mutation: "update", }), }), ); }); it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), executionRunId: "run-1", }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, })); mockHeartbeatService.getRun.mockResolvedValue({ id: "run-1", companyId: "company-1", agentId: "22222222-2222-4222-8222-222222222222", status: "running", }); mockHeartbeatService.cancelRun.mockResolvedValue({ id: "run-1", companyId: "company-1", agentId: "22222222-2222-4222-8222-222222222222", status: "cancelled", }); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); expect(res.status).toBe(200); expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "heartbeat.cancelled", details: expect.objectContaining({ source: "issue_comment_interrupt", issueId: "11111111-1111-4111-8111-111111111111", }), }), ); }); it("writes decision ids into executionState and inserts the decision inside the transaction", async () => { const policy = await normalizePolicy({ stages: [ { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", type: "approval", participants: [{ type: "user", userId: "local-board" }], }, ], })!; const issue = { ...makeIssue("todo"), status: "in_review", assigneeAgentId: null, assigneeUserId: "local-board", executionPolicy: policy, executionState: { status: "pending", currentStageId: policy.stages[0].id, currentStageIndex: 0, currentStageType: "approval", currentParticipant: { type: "user", userId: "local-board" }, returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record, tx?: unknown) => ({ ...issue, ...patch, executionState: patch.executionState, status: "done", completedAt: new Date(), updatedAt: new Date(), _tx: tx, })); const res = await request(await installActor(createApp())) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ status: "done", comment: "Approved for ship" }); expect(res.status).toBe(200); expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(mockIssueService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ executionState: expect.objectContaining({ status: "completed", lastDecisionId: expect.any(String), lastDecisionOutcome: "approved", }), }), mockTx, ); const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record; const decisionId = updatePatch.executionState.lastDecisionId; expect(mockTxInsertValues).toHaveBeenCalledWith( expect.objectContaining({ id: decisionId, issueId: "11111111-1111-4111-8111-111111111111", outcome: "approved", body: "Approved for ship", }), ); }); it("coerces executor handoff patches into workflow-controlled review wakes", async () => { const policy = await normalizePolicy({ stages: [ { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", type: "review", participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], }, ], })!; const issue = { ...makeIssue("todo"), status: "in_progress", assigneeAgentId: "22222222-2222-4222-8222-222222222222", executionPolicy: policy, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request( await installActor(createApp(), { type: "agent", agentId: "22222222-2222-4222-8222-222222222222", companyId: "company-1", runId: "run-1", }), ) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ status: "in_review", assigneeAgentId: null, assigneeUserId: "local-board", }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", assigneeUserId: null, executionState: expect.objectContaining({ status: "pending", currentStageType: "review", currentParticipant: expect.objectContaining({ type: "agent", agentId: "33333333-3333-4333-8333-333333333333", }), returnAssignee: expect.objectContaining({ type: "agent", agentId: "22222222-2222-4222-8222-222222222222", }), }), }), ); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "33333333-3333-4333-8333-333333333333", expect.objectContaining({ reason: "execution_review_requested", payload: expect.objectContaining({ issueId: "11111111-1111-4111-8111-111111111111", executionStage: expect.objectContaining({ wakeRole: "reviewer", stageType: "review", allowedActions: ["approve", "request_changes"], }), }), }), ); }); it("wakes the return assignee with execution_changes_requested", async () => { const policy = await normalizePolicy({ stages: [ { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", type: "review", participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], }, ], })!; const issue = { ...makeIssue("todo"), status: "in_review", assigneeAgentId: "33333333-3333-4333-8333-333333333333", executionPolicy: policy, executionState: { status: "pending", currentStageId: policy.stages[0].id, currentStageIndex: 0, currentStageType: "review", currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }, returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request( await installActor(createApp(), { type: "agent", agentId: "33333333-3333-4333-8333-333333333333", companyId: "company-1", runId: "run-2", }), ) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ status: "in_progress", comment: "Needs another pass", }); expect(res.status).toBe(200); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "execution_changes_requested", payload: expect.objectContaining({ issueId: "11111111-1111-4111-8111-111111111111", executionStage: expect.objectContaining({ wakeRole: "executor", stageType: "review", lastDecisionOutcome: "changes_requested", allowedActions: ["address_changes", "resubmit"], }), }), }), ); }); });