import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; const otherCompanyId = "33333333-3333-4333-8333-333333333333"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), assertCheckoutOwner: vi.fn(), })); const mockDocumentService = vi.hoisted(() => ({ getIssueDocumentByKey: vi.fn(), })); const mockAnnotationService = vi.hoisted(() => ({ listThreadsForIssueDocument: vi.fn(), getThreadForIssueDocument: vi.fn(), createThread: vi.fn(), addComment: vi.fn(), updateThread: vi.fn(), remapOpenThreadsForDocument: vi.fn(), })); const mockIssueReferenceService = vi.hoisted(() => ({ diffIssueReferenceSummary: vi.fn(() => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], })), emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })), listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })), syncAnnotationComment: vi.fn(async () => undefined), syncComment: vi.fn(async () => undefined), syncDocument: vi.fn(async () => undefined), syncIssue: vi.fn(async () => undefined), })); const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const documentPayload = { id: "document-1", companyId, issueId, key: "plan", title: "Plan", format: "markdown", body: "Alpha selected text omega", latestRevisionId: "44444444-4444-4444-8444-444444444444", latestRevisionNumber: 1, createdByAgentId: null, createdByUserId: "board-user", updatedByAgentId: null, updatedByUserId: "board-user", createdAt: new Date("2026-05-14T12:00:00.000Z"), updatedAt: new Date("2026-05-14T12:00:00.000Z"), }; const annotationThread = { id: "55555555-5555-4555-8555-555555555555", companyId, issueId, documentId: "document-1", documentKey: "plan", status: "open", anchorState: "active", anchorConfidence: "exact", originalRevisionId: documentPayload.latestRevisionId, originalRevisionNumber: 1, currentRevisionId: documentPayload.latestRevisionId, currentRevisionNumber: 1, selectedText: "selected text", prefixText: "Alpha ", suffixText: " omega", normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19, anchorSelector: { quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" }, position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 }, }, createdByAgentId: null, createdByUserId: "board-user", resolvedByAgentId: null, resolvedByUserId: null, resolvedAt: null, createdAt: new Date("2026-05-14T12:01:00.000Z"), updatedAt: new Date("2026-05-14T12:01:00.000Z"), }; const annotationComment = { id: "66666666-6666-4666-8666-666666666666", companyId, threadId: annotationThread.id, issueId, documentId: "document-1", body: "Please review PAP-1", authorType: "user", authorAgentId: null, authorUserId: "board-user", createdByRunId: null, createdAt: new Date("2026-05-14T12:01:00.000Z"), updatedAt: new Date("2026-05-14T12:01:00.000Z"), }; function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), hasPermission: vi.fn(async () => false) }), agentService: () => ({ getById: vi.fn(), list: vi.fn(async () => []) }), companyService: () => ({ getById: vi.fn(async () => ({ id: companyId, attachmentMaxBytes: 10_000_000 })) }), documentAnnotationService: () => mockAnnotationService, documentService: () => mockDocumentService, environmentService: () => ({}), executionWorkspaceService: () => ({}), feedbackService: () => ({}), goalService: () => ({}), heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => ({ get: vi.fn(async () => ({ id: "settings", general: {} })), getExperimental: vi.fn(async () => ({})), getGeneral: vi.fn(async () => ({})), listCompanyIds: vi.fn(async () => [companyId]), }), issueApprovalService: () => ({}), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), issueReferenceService: () => mockIssueReferenceService, issueService: () => mockIssueService, issueThreadInteractionService: () => ({ expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), }), logActivity: mockLogActivity, projectService: () => ({}), routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }), workProductService: () => ({}), })); } async function createApp(actor: "board" | "agent" = "board", actorCompanyId = companyId) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = actor === "agent" ? { type: "agent", agentId: "77777777-7777-4777-8777-777777777777", companyId: actorCompanyId, runId: "88888888-8888-4888-8888-888888888888", } : { type: "board", userId: "board-user", companyIds: [actorCompanyId], source: "local_implicit", isInstanceAdmin: false, }; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); app.use(errorHandler); return app; } describe("document annotation routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue({ id: issueId, companyId, title: "Annotation API", status: "in_progress", assigneeAgentId: null, }); mockIssueService.assertCheckoutOwner.mockResolvedValue({}); mockDocumentService.getIssueDocumentByKey.mockResolvedValue(documentPayload); mockAnnotationService.listThreadsForIssueDocument.mockImplementation(async ( _issueId: string, _key: string, options?: { includeComments?: boolean }, ) => ( options?.includeComments ? [{ ...annotationThread, comments: [annotationComment] }] : [annotationThread] )); mockAnnotationService.getThreadForIssueDocument.mockResolvedValue({ ...annotationThread, comments: [annotationComment] }); mockAnnotationService.createThread.mockResolvedValue({ ...annotationThread, comments: [annotationComment] }); mockAnnotationService.addComment.mockResolvedValue(annotationComment); mockAnnotationService.updateThread.mockResolvedValue({ ...annotationThread, status: "resolved" }); mockAnnotationService.remapOpenThreadsForDocument.mockResolvedValue([]); }); it("includes compact open annotations without comment bodies by default for agent document reads", async () => { const res = await request(await createApp("agent")) .get(`/api/issues/${issueId}/documents/plan`) .expect(200); expect(res.body.annotations).toHaveLength(1); expect(res.body.annotations[0].comments).toBeUndefined(); expect(mockAnnotationService.listThreadsForIssueDocument).toHaveBeenCalledWith(issueId, "plan", { status: "open", includeComments: false, }); }); it("includes annotation comment bodies on document reads only when explicitly requested", async () => { const res = await request(await createApp("agent")) .get(`/api/issues/${issueId}/documents/plan?includeAnnotationComments=true`) .expect(200); expect(res.body.annotations[0].comments[0].body).toBe("Please review PAP-1"); expect(mockAnnotationService.listThreadsForIssueDocument).toHaveBeenCalledWith(issueId, "plan", { status: "open", includeComments: true, }); }); it("creates annotation threads, syncs references, logs activity, and wakes the assignee", async () => { mockIssueService.getById.mockResolvedValue({ id: issueId, companyId, title: "Annotation API", status: "todo", assigneeAgentId: "99999999-9999-4999-8999-999999999999", }); const res = await request(await createApp()) .post(`/api/issues/${issueId}/documents/plan/annotations`) .send({ baseRevisionId: documentPayload.latestRevisionId, baseRevisionNumber: 1, selector: annotationThread.anchorSelector, body: "Please review PAP-1", }) .expect(201); expect(res.body.id).toBe(annotationThread.id); expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_created", })); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "99999999-9999-4999-8999-999999999999", expect.objectContaining({ payload: expect.objectContaining({ annotationThreadId: annotationThread.id, annotationCommentId: annotationComment.id, }), }), ); }); it("rejects agent cross-company annotation reads", async () => { await request(await createApp("agent", otherCompanyId)) .get(`/api/issues/${issueId}/documents/plan/annotations`) .expect(403); }); it("adds annotation comments and resolves threads", async () => { await request(await createApp()) .post(`/api/issues/${issueId}/documents/plan/annotations/${annotationThread.id}/comments`) .send({ body: "Reply with PAP-2" }) .expect(201); expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id); const resolved = await request(await createApp()) .patch(`/api/issues/${issueId}/documents/plan/annotations/${annotationThread.id}`) .send({ status: "resolved" }) .expect(200); expect(resolved.body.status).toBe("resolved"); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_resolved", })); }); });