import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import type { Company } from "@paperclipai/plugin-sdk"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; import manifest, { PLUGIN_ID, TRIAGE_ASSISTANT_AGENT_KEY, TRIAGE_MANAGED_SKILL_CANONICAL_KEYS, TRIAGE_MANAGED_SKILL_KEYS, TRIAGE_PROJECT_KEY, } from "../src/manifest.js"; import plugin, { createTriagePlugin } from "../src/worker.js"; import { createInMemoryTriageStore } from "../src/triage.js"; const COMPANY_ID = "company-1"; const OTHER_COMPANY_ID = "company-2"; function company(id: string, name = id): Company { const now = new Date(); return { id, name, description: null, status: "active", pauseReason: null, pausedAt: null, issuePrefix: id === COMPANY_ID ? "PAP" : "ALT", issueCounter: 1, budgetMonthlyCents: 0, spentMonthlyCents: 0, attachmentMaxBytes: 25_000_000, requireBoardApprovalForNewAgents: false, feedbackDataSharingEnabled: false, feedbackDataSharingConsentAt: null, feedbackDataSharingConsentByUserId: null, feedbackDataSharingTermsVersion: null, brandColor: null, logoAssetId: null, logoUrl: null, createdAt: now, updatedAt: now, }; } describe("Paperclip Triage scaffold", () => { it("declares the package manifest, UI slots, and managed resources", () => { expect(manifest).toMatchObject({ id: PLUGIN_ID, }); expect(manifest.database).toEqual({ namespaceSlug: "triage", migrationsDir: "migrations", coreReadTables: ["companies", "issues"], }); expect(manifest.apiRoutes).toEqual([ expect.objectContaining({ routeKey: "items.ingest", method: "POST", path: "/queues/:queueKey/items", }), ]); expect(manifest.capabilities).toEqual(expect.arrayContaining([ "api.routes.register", "database.namespace.migrate", "database.namespace.read", "database.namespace.write", "companies.read", "issues.read", "issues.create", "issues.update", "issue.comments.read", "issue.comments.create", "agents.managed", "projects.managed", "skills.managed", "instance.settings.register", "ui.sidebar.register", "ui.page.register", ])); expect(manifest.ui?.slots).toEqual(expect.arrayContaining([ expect.objectContaining({ type: "sidebar", exportName: "SidebarLink" }), expect.objectContaining({ type: "page", exportName: "TriagePage", routePath: "triage" }), expect.objectContaining({ type: "routeSidebar", exportName: "TriageRouteSidebar", routePath: "triage" }), expect.objectContaining({ type: "settingsPage", exportName: "SettingsPage" }), ])); expect(manifest.agents?.[0]).toEqual(expect.objectContaining({ agentKey: TRIAGE_ASSISTANT_AGENT_KEY, displayName: "Triage Assistant", status: "paused", permissions: { pluginTools: [PLUGIN_ID] }, })); expect(manifest.agents?.[0]?.adapterConfig?.paperclipSkillSync).toEqual({ desiredSkills: TRIAGE_MANAGED_SKILL_CANONICAL_KEYS, }); expect(manifest.projects?.[0]).toEqual(expect.objectContaining({ projectKey: TRIAGE_PROJECT_KEY, displayName: "Triage", })); expect(manifest.skills?.map((skill) => skill.skillKey)).toEqual(TRIAGE_MANAGED_SKILL_KEYS); }); it("declares the plugin-owned triage migration shape", () => { const migration = readFileSync(new URL("../migrations/001_triage_core.sql", import.meta.url), "utf8"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queues"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_states"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_transitions"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_items"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_chats"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_guidance_docs"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_guidance_doc_revisions"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_guidance_proposals"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_item_events"); expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_transition_actions"); expect(migration).toContain("REFERENCES public.companies(id)"); expect(migration).toContain("REFERENCES public.issues(id)"); expect(migration).toContain("CREATE UNIQUE INDEX triage_items_queue_item_key_idx"); expect(migration).toContain("CREATE UNIQUE INDEX triage_items_queue_idempotency_key_idx"); }); it("reports missing managed resources before reconcile", async () => { const harness = createTestHarness({ manifest }); await plugin.definition.setup(harness.ctx); const health = await harness.getData<{ status: string; skills: Array<{ status: string }> }>( "managed-resource-health", { companyId: COMPANY_ID }, ); expect(health.status).toBe("missing"); expect(health.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length); expect(health.skills.every((skill) => skill.status === "missing")).toBe(true); }); it("reconciles managed project, assistant, and skills", async () => { const harness = createTestHarness({ manifest }); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ status: string; agent: { status: string; agentId: string | null }; project: { status: string; projectId: string | null }; skills: Array<{ status: string; skillId: string | null; key: string | null }>; }>("reconcile-managed-resources", { companyId: COMPANY_ID }); expect(result.status).toBe("ready"); expect(result.agent).toEqual(expect.objectContaining({ status: "created" })); expect(result.agent.agentId).toBeTruthy(); expect(result.project).toEqual(expect.objectContaining({ status: "created" })); expect(result.project.projectId).toBeTruthy(); expect(result.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length); expect(result.skills.every((skill) => skill.status === "created")).toBe(true); expect(result.skills.map((skill) => skill.key)).toEqual(TRIAGE_MANAGED_SKILL_CANONICAL_KEYS); }); }); describe("Paperclip Triage queue and item ingest", () => { function createHarness() { const store = createInMemoryTriageStore(); const testPlugin = createTriagePlugin({ createStore: () => store }); const harness = createTestHarness({ manifest }); harness.seed({ companies: [company(COMPANY_ID), company(OTHER_COMPANY_ID)] }); return { harness, plugin: testPlugin, store }; } it("creates and updates queues through worker actions", async () => { const { harness, plugin: testPlugin } = createHarness(); await testPlugin.definition.setup(harness.ctx); const created = await harness.performAction<{ queue: { queueKey: string; title: string }; created: boolean }>( "create-queue", { companyId: COMPANY_ID, queueKey: "reviews", title: "Reviews" }, ); expect(created).toMatchObject({ created: true, queue: { queueKey: "reviews", title: "Reviews" }, }); await harness.performAction("update-queue", { companyId: COMPANY_ID, queueKey: "reviews", title: "Editorial Reviews", }); await expect(harness.getData("queue", { companyId: COMPANY_ID, queueKey: "reviews" })) .resolves.toMatchObject({ queueKey: "reviews", title: "Editorial Reviews" }); await expect(harness.getData("queues", { companyId: OTHER_COMPANY_ID })) .resolves.toEqual([]); }); it("strictly rejects missing queues when requireExistingQueue is true", async () => { const { harness, plugin: testPlugin } = createHarness(); await testPlugin.definition.setup(harness.ctx); await expect(harness.performAction("ingest-item", { companyId: COMPANY_ID, queueKey: "unknown", title: "Should not create", requireExistingQueue: true, })).rejects.toMatchObject({ status: 404, code: "queue_not_found", }); await expect(harness.getData("queues", { companyId: COMPANY_ID })).resolves.toEqual([]); }); it("auto-creates a queue on ingest and upserts by stable item key", async () => { const { harness, plugin: testPlugin } = createHarness(); await testPlugin.definition.setup(harness.ctx); const first = await harness.performAction<{ createdQueue: boolean; createdItem: boolean; item: { id: string; title: string; content: string; revision: number }; queue: { queueKey: string; activeItemCount: number }; }>("ingest-item", { companyId: COMPANY_ID, queueKey: "content-training", title: "Draft launch post", content: "# Draft", itemKey: "external-1", properties: { sourceKind: "fixture" }, }); expect(first).toMatchObject({ createdQueue: true, createdItem: true, item: { title: "Draft launch post", content: "# Draft", revision: 1 }, queue: { queueKey: "content-training", activeItemCount: 1 }, }); const second = await harness.performAction<{ createdQueue: boolean; createdItem: boolean; upserted: boolean; item: { id: string; title: string; content: string; revision: number }; queue: { activeItemCount: number }; }>("ingest-item", { companyId: COMPANY_ID, queueKey: "content-training", title: "Revised launch post", content: "# Revised", itemKey: "external-1", }); expect(second).toMatchObject({ createdQueue: false, createdItem: false, upserted: true, item: { id: first.item.id, title: "Revised launch post", content: "# Revised", revision: 2 }, queue: { activeItemCount: 1 }, }); await expect(harness.getData("queue-items", { companyId: COMPANY_ID, queueKey: "content-training" })) .resolves.toHaveLength(1); }); it("keeps queue keys company-scoped and validates scoped API company context", async () => { const { harness, plugin: testPlugin } = createHarness(); await testPlugin.definition.setup(harness.ctx); await harness.performAction("ingest-item", { companyId: COMPANY_ID, queueKey: "inbox", title: "Company one item", idempotencyKey: "same-upstream-request", }); await harness.performAction("ingest-item", { companyId: OTHER_COMPANY_ID, queueKey: "inbox", title: "Company two item", idempotencyKey: "same-upstream-request", }); await expect(harness.getData("queue-items", { companyId: COMPANY_ID, queueKey: "inbox" })) .resolves.toEqual([expect.objectContaining({ title: "Company one item" })]); await expect(harness.getData("queue-items", { companyId: OTHER_COMPANY_ID, queueKey: "inbox" })) .resolves.toEqual([expect.objectContaining({ title: "Company two item" })]); const mismatch = await testPlugin.definition.onApiRequest?.({ routeKey: "items.ingest", method: "POST", path: "/queues/inbox/items", params: { queueKey: "inbox" }, query: {}, body: { companyId: OTHER_COMPANY_ID, title: "Wrong company", }, actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null, }, companyId: COMPANY_ID, headers: {}, }); expect(mismatch).toEqual({ status: 403, body: { error: { code: "company_scope_mismatch", message: "Request companyId does not match the resolved plugin route company", }, }, }); }); it("reuses one hidden queue chat and assistant session per queue while isolating other queues", async () => { const { harness, plugin: testPlugin } = createHarness(); await testPlugin.definition.setup(harness.ctx); const first = await harness.performAction<{ item: { id: string }; }>("ingest-item", { companyId: COMPANY_ID, queueKey: "reviews", title: "Launch review", content: "Please review the launch copy.", properties: { sourceKind: "manual" }, }); const other = await harness.performAction<{ item: { id: string }; }>("ingest-item", { companyId: COMPANY_ID, queueKey: "support", title: "Support review", content: "Please review the support reply.", }); const firstSend = await harness.performAction<{ chat: { id: string; hiddenIssueId: string; metadata: Record }; hiddenIssueId: string; session: { sessionId: string }; runId: string; prompt: string; }>("send-assistant-message", { companyId: COMPANY_ID, itemId: first.item.id, message: "What should I change?", }); expect(firstSend.hiddenIssueId).toBeTruthy(); expect(firstSend.chat.hiddenIssueId).toBe(firstSend.hiddenIssueId); expect(firstSend.runId).toBeTruthy(); expect(firstSend.prompt).toContain("Queue: Reviews (reviews)"); expect(firstSend.prompt).toContain("Please review the launch copy."); expect(firstSend.prompt).toContain("guidance.md"); expect(firstSend.prompt).toContain("What should I change?"); const secondSend = await harness.performAction<{ chat: { id: string; hiddenIssueId: string }; hiddenIssueId: string; session: { sessionId: string }; }>("send-assistant-message", { companyId: COMPANY_ID, itemId: first.item.id, message: "Use the same thread.", }); expect(secondSend.chat.id).toBe(firstSend.chat.id); expect(secondSend.hiddenIssueId).toBe(firstSend.hiddenIssueId); expect(secondSend.session.sessionId).toBe(firstSend.session.sessionId); const otherSend = await harness.performAction<{ chat: { id: string }; hiddenIssueId: string; }>("send-assistant-message", { companyId: COMPANY_ID, itemId: other.item.id, message: "Different queue.", }); expect(otherSend.chat.id).not.toBe(firstSend.chat.id); expect(otherSend.hiddenIssueId).not.toBe(firstSend.hiddenIssueId); }); it("updates item content only when the expected revision matches", async () => { const { harness, plugin: testPlugin } = createHarness(); await testPlugin.definition.setup(harness.ctx); const ingested = await harness.performAction<{ item: { id: string; revision: number }; }>("ingest-item", { companyId: COMPANY_ID, queueKey: "reviews", title: "Original", content: "Original content", }); const updated = await harness.performAction<{ item: { title: string; content: string; revision: number }; event: { eventType: string; metadata: Record }; }>("update-item-content", { companyId: COMPANY_ID, itemId: ingested.item.id, expectedRevision: ingested.item.revision, title: "Edited", content: "Edited content", }); expect(updated.item).toMatchObject({ title: "Edited", content: "Edited content", revision: 2 }); expect(updated.event).toMatchObject({ eventType: "item.content.updated", metadata: { previousRevision: 1, nextRevision: 2 }, }); await expect(harness.performAction("update-item-content", { companyId: COMPANY_ID, itemId: ingested.item.id, expectedRevision: 1, content: "Stale edit", })).rejects.toMatchObject({ status: 409, code: "item_revision_conflict", }); }); it("supports guidance proposal generation, revision, acceptance, rejection, and manual edits", async () => { const { harness, plugin: testPlugin } = createHarness(); await testPlugin.definition.setup(harness.ctx); const ingested = await harness.performAction<{ item: { id: string }; }>("ingest-item", { companyId: COMPANY_ID, queueKey: "reviews", title: "Review example", content: "Prefer concise copy.", }); const manual = await harness.performAction<{ doc: { content: string; currentRevisionId: string }; revision: { id: string; summary: string }; }>("manual-edit-guidance", { companyId: COMPANY_ID, queueKey: "reviews", content: "# Reviews Guidance\n\nStart from the user's target audience.", summary: "Seed guidance", }); expect(manual.doc.content).toContain("target audience"); expect(manual.doc.currentRevisionId).toBe(manual.revision.id); const generated = await harness.performAction<{ proposal: { id: string; status: string; proposedContent: string }; }>("generate-guidance-proposal", { companyId: COMPANY_ID, itemId: ingested.item.id, suggestedChange: "For launch copy, flag vague benefits before approving.", rationale: "The item exposed a repeatable approval rule.", }); expect(generated.proposal.status).toBe("proposed"); expect(generated.proposal.proposedContent).toContain("target audience"); expect(generated.proposal.proposedContent).toContain("flag vague benefits"); const revised = await harness.performAction<{ status: string; proposedContent: string }>( "revise-guidance-proposal", { companyId: COMPANY_ID, proposalId: generated.proposal.id, proposedContent: "# Reviews Guidance\n\nFlag vague benefits and ask for concrete proof.", rationale: "Sharper wording.", }, ); expect(revised).toMatchObject({ status: "revised" }); const accepted = await harness.performAction<{ proposal: { status: string; metadata: Record }; doc: { content: string; currentRevisionId: string }; revision: { id: string }; }>("accept-guidance-proposal", { companyId: COMPANY_ID, proposalId: generated.proposal.id, }); expect(accepted.proposal.status).toBe("accepted"); expect(accepted.proposal.metadata.acceptedRevisionId).toBe(accepted.revision.id); expect(accepted.doc.content).toContain("concrete proof"); expect(accepted.doc.currentRevisionId).toBe(accepted.revision.id); const rejectedCandidate = await harness.performAction<{ proposal: { id: string }; }>("generate-guidance-proposal", { companyId: COMPANY_ID, itemId: ingested.item.id, proposedContent: "# Reviews Guidance\n\nToo broad.", }); const rejected = await harness.performAction<{ status: string; metadata: Record }>( "reject-guidance-proposal", { companyId: COMPANY_ID, proposalId: rejectedCandidate.proposal.id, reason: "Too broad for this queue.", }, ); expect(rejected).toMatchObject({ status: "rejected", metadata: expect.objectContaining({ rejectionReason: "Too broad for this queue." }), }); await expect(harness.performAction("accept-guidance-proposal", { companyId: COMPANY_ID, proposalId: rejectedCandidate.proposal.id, })).rejects.toMatchObject({ status: 409, code: "guidance_proposal_rejected", }); }); });