From daf314e3729246440bc1275aec503ba1a8a55c05 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 19 May 2026 13:14:10 -0500 Subject: [PATCH] Add Paperclip Triage plugin scaffold --- packages/plugins/plugin-triage/.gitignore | 3 + packages/plugins/plugin-triage/README.md | 51 +++ .../plugins/plugin-triage/esbuild.config.mjs | 17 + packages/plugins/plugin-triage/package.json | 47 +++ .../plugins/plugin-triage/rollup.config.mjs | 28 ++ .../plugins/plugin-triage/src/manifest.ts | 190 +++++++++++ .../plugins/plugin-triage/src/ui/index.tsx | 297 ++++++++++++++++++ packages/plugins/plugin-triage/src/worker.ts | 164 ++++++++++ .../plugin-triage/tests/plugin.spec.ts | 81 +++++ packages/plugins/plugin-triage/tsconfig.json | 27 ++ .../plugins/plugin-triage/vitest.config.ts | 8 + 11 files changed, 913 insertions(+) create mode 100644 packages/plugins/plugin-triage/.gitignore create mode 100644 packages/plugins/plugin-triage/README.md create mode 100644 packages/plugins/plugin-triage/esbuild.config.mjs create mode 100644 packages/plugins/plugin-triage/package.json create mode 100644 packages/plugins/plugin-triage/rollup.config.mjs create mode 100644 packages/plugins/plugin-triage/src/manifest.ts create mode 100644 packages/plugins/plugin-triage/src/ui/index.tsx create mode 100644 packages/plugins/plugin-triage/src/worker.ts create mode 100644 packages/plugins/plugin-triage/tests/plugin.spec.ts create mode 100644 packages/plugins/plugin-triage/tsconfig.json create mode 100644 packages/plugins/plugin-triage/vitest.config.ts diff --git a/packages/plugins/plugin-triage/.gitignore b/packages/plugins/plugin-triage/.gitignore new file mode 100644 index 00000000000..0430cc35608 --- /dev/null +++ b/packages/plugins/plugin-triage/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.paperclip-sdk diff --git a/packages/plugins/plugin-triage/README.md b/packages/plugins/plugin-triage/README.md new file mode 100644 index 00000000000..e3f8842fc33 --- /dev/null +++ b/packages/plugins/plugin-triage/README.md @@ -0,0 +1,51 @@ +# Paperclip Triage + +Queue workbench for triaging arbitrary items with a teachable assistant. + +## Development + +```bash +pnpm install +pnpm dev # watch builds +pnpm dev:ui # local dev server with hot-reload events +pnpm typecheck +pnpm test +pnpm build +``` + +`pnpm dev` rebuilds the worker, manifest, and UI bundles into `dist/`. +When this package is installed from a local path, Paperclip watches that rebuilt +output and reloads the plugin worker. Local installs run trusted code from this +folder on your machine. + +## Phase 2 Scope + +This package scaffold declares: + +- plugin id `paperclipai.plugin-triage` +- page, sidebar, route sidebar, and settings page slots +- managed `Triage Assistant` agent +- managed `Triage` project +- managed triage skills +- worker health data plus a settings-page reconcile action + +## Install Into Paperclip + +```bash +paperclipai plugin install /Users/dotta/paperclip/.paperclip/worktrees/PAP-9815-triage-plugin/packages/plugins/plugin-triage +``` + +## Build Options + +- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`. +- `pnpm build:rollup` uses rollup presets from the same SDK. + +## Focused Verification + +From the repository root: + +```bash +pnpm --filter @paperclipai/plugin-triage typecheck +pnpm --filter @paperclipai/plugin-triage test +pnpm --filter @paperclipai/plugin-triage build +``` diff --git a/packages/plugins/plugin-triage/esbuild.config.mjs b/packages/plugins/plugin-triage/esbuild.config.mjs new file mode 100644 index 00000000000..b5cfd36ed95 --- /dev/null +++ b/packages/plugins/plugin-triage/esbuild.config.mjs @@ -0,0 +1,17 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} diff --git a/packages/plugins/plugin-triage/package.json b/packages/plugins/plugin-triage/package.json new file mode 100644 index 00000000000..941b028ef9a --- /dev/null +++ b/packages/plugins/plugin-triage/package.json @@ -0,0 +1,47 @@ +{ + "name": "@paperclipai/plugin-triage", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Queue workbench for triaging arbitrary items with a teachable assistant.", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", + "build": "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + "dev": "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "automation" + ], + "author": "Paperclip", + "license": "MIT", + "devDependencies": { + "@paperclipai/plugin-sdk": "workspace:*", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "esbuild": "^0.27.3", + "rollup": "^4.38.0", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/plugin-triage/rollup.config.mjs b/packages/plugins/plugin-triage/rollup.config.mjs new file mode 100644 index 00000000000..ccee40a7443 --- /dev/null +++ b/packages/plugins/plugin-triage/rollup.config.mjs @@ -0,0 +1,28 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); diff --git a/packages/plugins/plugin-triage/src/manifest.ts b/packages/plugins/plugin-triage/src/manifest.ts new file mode 100644 index 00000000000..4c54dc2929b --- /dev/null +++ b/packages/plugins/plugin-triage/src/manifest.ts @@ -0,0 +1,190 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +export const PLUGIN_ID = "paperclipai.plugin-triage"; +export const TRIAGE_ASSISTANT_AGENT_KEY = "triage-assistant"; +export const TRIAGE_PROJECT_KEY = "triage"; +export const TRIAGE_ASSISTANT_SKILL_KEY = "triage-assistant"; +export const TRIAGE_REFLECTION_SKILL_KEY = "triage-reflection"; +export const TRIAGE_WORKFLOW_SKILL_KEY = "triage-workflow"; +export const TRIAGE_MANAGED_SKILL_KEYS = [ + TRIAGE_ASSISTANT_SKILL_KEY, + TRIAGE_REFLECTION_SKILL_KEY, + TRIAGE_WORKFLOW_SKILL_KEY, +] as const; +export const TRIAGE_MANAGED_SKILL_CANONICAL_KEYS = TRIAGE_MANAGED_SKILL_KEYS.map(managedSkillCanonicalKey); + +function managedSkillCanonicalKey(skillKey: string): string { + return `plugin/paperclipai-plugin-triage/${skillKey}`; +} + +function skillMarkdown(skillKey: (typeof TRIAGE_MANAGED_SKILL_KEYS)[number]): string { + if (skillKey === TRIAGE_REFLECTION_SKILL_KEY) { + return `# Triage Reflection + +Use when proposing queue guidance updates after a user and assistant finish processing a queue item. + +- Compare the original item, final item, chat outcome, transition decision, and current queue guidance. +- Propose guidance changes only when the next item would benefit from them. +- Never apply guidance directly; return a proposal for board/user review. +- Keep source-specific behavior out of the queue unless it is already represented in item properties or queue guidance. +`; + } + + if (skillKey === TRIAGE_WORKFLOW_SKILL_KEY) { + return `# Triage Workflow + +Use when reasoning about Paperclip Triage queue states, transitions, and work issue actions. + +- Treat queue items as company-scoped records with opaque content and properties. +- Use only configured workflow transitions from the item's current state. +- Respect reflection gates before approving or rejecting an item. +- Limit downstream effects to create-or-update Paperclip issue actions. +- Do not call external systems or mutate upstream sources. +`; + } + + return `# Triage Assistant + +Use when helping a user process items in a Paperclip Triage queue. + +- Work from the queue purpose, current item content, item properties, workflow state, and guidance documents. +- Keep chat-assisted edits and direct edits focused on the same queue item record. +- Ask for user approval before guidance changes or irreversible workflow decisions. +- Preserve company boundaries and keep all queue work scoped to the active company. +- Do not add source connector behavior; ingestion starts when an item is posted into a queue. +`; +} + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.1.0", + displayName: "Paperclip Triage", + description: "Queue workbench for triaging arbitrary items with a teachable assistant.", + author: "Paperclip", + categories: ["automation", "ui"], + capabilities: [ + "agents.managed", + "projects.managed", + "skills.managed", + "instance.settings.register", + "ui.sidebar.register", + "ui.page.register", + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + agents: [ + { + agentKey: TRIAGE_ASSISTANT_AGENT_KEY, + displayName: "Triage Assistant", + role: "triage-assistant", + title: "Triage Assistant", + icon: "inbox", + capabilities: "Helps process queue items, apply queue guidance, propose guidance updates, and prepare Paperclip work issue handoffs.", + adapterType: "claude_local", + adapterPreference: ["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor", "pi_local"], + adapterConfig: { + dangerouslySkipPermissions: false, + dangerouslyBypassApprovalsAndSandbox: false, + sandbox: true, + paperclipSkillSync: { + desiredSkills: TRIAGE_MANAGED_SKILL_CANONICAL_KEYS, + }, + }, + runtimeConfig: { + modelProfiles: { + cheap: { + purpose: "queue classification, transition suggestions, and reflection draft review", + }, + }, + }, + permissions: { + pluginTools: [PLUGIN_ID], + }, + status: "paused", + budgetMonthlyCents: 0, + instructions: { + entryFile: "AGENTS.md", + content: [ + "# Triage Assistant", + "", + "You are the managed assistant for Paperclip Triage queues.", + "", + "Work inside the active company only. Use queue guidance, current item content, item properties, and workflow state as your source of truth.", + "", + "Do not build source connectors or call upstream systems. Do not apply guidance changes without explicit user acceptance. Downstream actions are limited to Paperclip issue create-or-update templates.", + ].join("\n"), + }, + }, + ], + projects: [ + { + projectKey: TRIAGE_PROJECT_KEY, + displayName: "Triage", + description: "Plugin-managed project for Paperclip Triage assistant work, queue chat issues, and generated work issue defaults.", + status: "in_progress", + color: "#0f766e", + settings: { + defaultWorkflow: "new -> approved/rejected -> done", + }, + }, + ], + skills: [ + { + skillKey: TRIAGE_ASSISTANT_SKILL_KEY, + displayName: "Triage Assistant", + slug: TRIAGE_ASSISTANT_SKILL_KEY, + description: "Process queue items with user-visible guidance and company-scoped queue context.", + markdown: skillMarkdown(TRIAGE_ASSISTANT_SKILL_KEY), + }, + { + skillKey: TRIAGE_REFLECTION_SKILL_KEY, + displayName: "Triage Reflection", + slug: TRIAGE_REFLECTION_SKILL_KEY, + description: "Propose queue guidance updates after item processing without applying them automatically.", + markdown: skillMarkdown(TRIAGE_REFLECTION_SKILL_KEY), + }, + { + skillKey: TRIAGE_WORKFLOW_SKILL_KEY, + displayName: "Triage Workflow", + slug: TRIAGE_WORKFLOW_SKILL_KEY, + description: "Reason about queue states, reflection gates, and create-or-update issue actions.", + markdown: skillMarkdown(TRIAGE_WORKFLOW_SKILL_KEY), + }, + ], + ui: { + slots: [ + { + type: "sidebar", + id: "triage-sidebar", + displayName: "Triage", + exportName: "SidebarLink", + order: 38, + }, + { + type: "page", + id: "triage-page", + displayName: "Triage", + exportName: "TriagePage", + routePath: "triage", + }, + { + type: "routeSidebar", + id: "triage-route-sidebar", + displayName: "Triage", + exportName: "TriageRouteSidebar", + routePath: "triage", + }, + { + type: "settingsPage", + id: "triage-settings", + displayName: "Triage", + exportName: "SettingsPage", + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/plugin-triage/src/ui/index.tsx b/packages/plugins/plugin-triage/src/ui/index.tsx new file mode 100644 index 00000000000..260b5b27c4e --- /dev/null +++ b/packages/plugins/plugin-triage/src/ui/index.tsx @@ -0,0 +1,297 @@ +import { + useHostNavigation, + usePluginAction, + usePluginData, + usePluginToast, + type PluginPageProps, + type PluginRouteSidebarProps, + type PluginSettingsPageProps, + type PluginSidebarProps, +} from "@paperclipai/plugin-sdk/ui"; +import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; + +type ManagedResourceHealth = { + status: "needs_company" | "missing" | "ready"; + checkedAt: string; + agent: { + resourceKey: string; + status: string; + agentId: string | null; + name: string | null; + agentStatus: string | null; + adapterType: string | null; + } | null; + project: { + resourceKey: string; + status: string; + projectId: string | null; + name: string | null; + projectStatus: string | null; + } | null; + skills: Array<{ + resourceKey: string; + status: string; + skillId: string | null; + name: string | null; + key: string | null; + }>; +}; + +const tokens = { + border: "var(--border, oklch(0.86 0 0))", + bg: "var(--background, #ffffff)", + fg: "var(--foreground, #18181b)", + muted: "var(--muted-foreground, #71717a)", + accent: "var(--accent, #f4f4f5)", + accentFg: "var(--accent-foreground, #18181b)", + primary: "var(--primary, #18181b)", + primaryFg: "var(--primary-foreground, #ffffff)", + success: "oklch(0.55 0.13 155)", + warning: "oklch(0.64 0.14 75)", +}; + +const pageShell: CSSProperties = { + display: "grid", + gap: 20, + padding: 24, + color: tokens.fg, + background: tokens.bg, + minHeight: "100%", + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", +}; + +const panelStyle: CSSProperties = { + border: `1px solid ${tokens.border}`, + borderRadius: 8, + padding: 16, + display: "grid", + gap: 12, +}; + +const mutedStyle: CSSProperties = { + color: tokens.muted, + fontSize: 13, +}; + +function useManagedResourceHealth(companyId: string | null) { + const params = useMemo(() => ({ companyId: companyId ?? "" }), [companyId]); + return usePluginData("managed-resource-health", params); +} + +function statusTone(status: string): CSSProperties { + if (status === "ready" || status === "resolved" || status === "created") { + return { color: tokens.success }; + } + if (status === "missing" || status === "needs_company") { + return { color: tokens.warning }; + } + return { color: tokens.muted }; +} + +function StatusText({ status }: { status: string }) { + return ( + + {status.replaceAll("_", " ")} + + ); +} + +function ResourceRow({ + label, + status, + detail, +}: { + label: string; + status: string; + detail?: ReactNode; +}) { + return ( +
+
+ {label} + {detail ? {detail} : null} +
+ +
+ ); +} + +function ManagedResourcePanel({ + companyId, + allowReconcile, +}: { + companyId: string | null; + allowReconcile: boolean; +}) { + const { data, loading, error, refresh } = useManagedResourceHealth(companyId); + const reconcile = usePluginAction("reconcile-managed-resources"); + const toast = usePluginToast(); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + + async function handleReconcile() { + if (!companyId) return; + setBusy(true); + setActionError(null); + try { + await reconcile({ companyId }); + refresh(); + toast({ title: "Triage resources reconciled", tone: "success" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to reconcile resources"; + setActionError(message); + toast({ title: "Reconcile failed", body: message, tone: "error" }); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+

Managed Resources

+
Last checked: {data?.checkedAt ?? "not checked"}
+
+ {allowReconcile ? ( + + ) : null} +
+ + {loading ?
Loading resources...
: null} + {error ?
{error.message}
: null} + {actionError ?
{actionError}
: null} + + {data ? ( +
+ + + + {data.skills.map((skill) => ( + + ))} +
+ ) : null} +
+ ); +} + +export function SidebarLink({ context }: PluginSidebarProps) { + const nav = useHostNavigation(); + return ( + + Triage + + ); +} + +export function TriageRouteSidebar({ context }: PluginRouteSidebarProps) { + const nav = useHostNavigation(); + const { data } = useManagedResourceHealth(context.companyId); + + return ( + + ); +} + +export function TriagePage({ context }: PluginPageProps) { + return ( +
+
+

Triage

+
Queue workbench scaffold
+
+ +
+
+

Queues

+
Queue data model is implemented in the next phase.
+
+
+ +
+ +
+
+ ); +} + +export function SettingsPage({ context }: PluginSettingsPageProps) { + return ( +
+
+

Triage Settings

+
Managed project, assistant, and skills
+
+ +
+ ); +} diff --git a/packages/plugins/plugin-triage/src/worker.ts b/packages/plugins/plugin-triage/src/worker.ts new file mode 100644 index 00000000000..cf8ed9a0143 --- /dev/null +++ b/packages/plugins/plugin-triage/src/worker.ts @@ -0,0 +1,164 @@ +import { + definePlugin, + runWorker, + type PluginContext, + type PluginManagedAgentResolution, + type PluginManagedProjectResolution, + type PluginManagedSkillResolution, +} from "@paperclipai/plugin-sdk"; +import { + TRIAGE_ASSISTANT_AGENT_KEY, + TRIAGE_MANAGED_SKILL_KEYS, + TRIAGE_PROJECT_KEY, +} from "./manifest.js"; + +type ManagedResourceHealth = { + status: "needs_company" | "missing" | "ready"; + checkedAt: string; + agent: ManagedAgentHealth | null; + project: ManagedProjectHealth | null; + skills: ManagedSkillHealth[]; +}; + +type ManagedAgentHealth = { + resourceKey: string; + status: PluginManagedAgentResolution["status"]; + agentId: string | null; + name: string | null; + agentStatus: string | null; + adapterType: string | null; +}; + +type ManagedProjectHealth = { + resourceKey: string; + status: PluginManagedProjectResolution["status"]; + projectId: string | null; + name: string | null; + projectStatus: string | null; +}; + +type ManagedSkillHealth = { + resourceKey: string; + status: PluginManagedSkillResolution["status"]; + skillId: string | null; + name: string | null; + key: string | null; +}; + +function stringField(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function requireCompanyId(params: Record): string { + const companyId = stringField(params.companyId); + if (!companyId) { + throw new Error("companyId is required"); + } + return companyId; +} + +function summarizeAgent(resolution: PluginManagedAgentResolution): ManagedAgentHealth { + return { + resourceKey: resolution.resourceKey, + status: resolution.status, + agentId: resolution.agentId, + name: resolution.agent?.name ?? null, + agentStatus: resolution.agent?.status ?? null, + adapterType: resolution.agent?.adapterType ?? null, + }; +} + +function summarizeProject(resolution: PluginManagedProjectResolution): ManagedProjectHealth { + return { + resourceKey: resolution.resourceKey, + status: resolution.status, + projectId: resolution.projectId, + name: resolution.project?.name ?? null, + projectStatus: resolution.project?.status ?? null, + }; +} + +function summarizeSkill(resolution: PluginManagedSkillResolution): ManagedSkillHealth { + return { + resourceKey: resolution.resourceKey, + status: resolution.status, + skillId: resolution.skillId, + name: resolution.skill?.name ?? null, + key: resolution.skill?.key ?? null, + }; +} + +async function managedResourceHealth( + ctx: PluginContext, + companyId: string, + mode: "inspect" | "reconcile", +): Promise { + const projectResolution = mode === "reconcile" + ? await ctx.projects.managed.reconcile(TRIAGE_PROJECT_KEY, companyId) + : await ctx.projects.managed.get(TRIAGE_PROJECT_KEY, companyId); + const skillResolutions = await Promise.all( + TRIAGE_MANAGED_SKILL_KEYS.map((skillKey) => + mode === "reconcile" + ? ctx.skills.managed.reconcile(skillKey, companyId) + : ctx.skills.managed.get(skillKey, companyId), + ), + ); + const agentResolution = mode === "reconcile" + ? await ctx.agents.managed.reconcile(TRIAGE_ASSISTANT_AGENT_KEY, companyId) + : await ctx.agents.managed.get(TRIAGE_ASSISTANT_AGENT_KEY, companyId); + + const agent = summarizeAgent(agentResolution); + const project = summarizeProject(projectResolution); + const skills = skillResolutions.map(summarizeSkill); + const missing = [ + agent.status === "missing", + project.status === "missing", + ...skills.map((skill) => skill.status === "missing"), + ].some(Boolean); + + return { + status: missing ? "missing" : "ready", + checkedAt: new Date().toISOString(), + agent, + project, + skills, + }; +} + +const plugin = definePlugin({ + async setup(ctx) { + ctx.data.register("managed-resource-health", async (params: Record) => { + const companyId = stringField(params.companyId); + if (!companyId) { + return { + status: "needs_company", + checkedAt: new Date().toISOString(), + agent: null, + project: null, + skills: [], + } satisfies ManagedResourceHealth; + } + + return managedResourceHealth(ctx, companyId, "inspect"); + }); + + ctx.actions.register("reconcile-managed-resources", async (params: Record) => { + const companyId = requireCompanyId(params); + const result = await managedResourceHealth(ctx, companyId, "reconcile"); + ctx.logger.info("Reconciled Paperclip Triage managed resources", { + companyId, + agentStatus: result.agent?.status, + projectStatus: result.project?.status, + skillStatuses: result.skills.map((skill) => `${skill.resourceKey}:${skill.status}`), + }); + return result; + }); + }, + + async onHealth() { + return { status: "ok", message: "Paperclip Triage worker is running" }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/plugin-triage/tests/plugin.spec.ts b/packages/plugins/plugin-triage/tests/plugin.spec.ts new file mode 100644 index 00000000000..cfeedfeb236 --- /dev/null +++ b/packages/plugins/plugin-triage/tests/plugin.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +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 from "../src/worker.js"; + +const COMPANY_ID = "company-1"; + +describe("Paperclip Triage scaffold", () => { + it("declares the package manifest, UI slots, and managed resources", () => { + expect(manifest.id).toBe(PLUGIN_ID); + expect(manifest.capabilities).toEqual(expect.arrayContaining([ + "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("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); + }); +}); diff --git a/packages/plugins/plugin-triage/tsconfig.json b/packages/plugins/plugin-triage/tsconfig.json new file mode 100644 index 00000000000..a697519ed9d --- /dev/null +++ b/packages/plugins/plugin-triage/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM" + ], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/plugins/plugin-triage/vitest.config.ts b/packages/plugins/plugin-triage/vitest.config.ts new file mode 100644 index 00000000000..649a293ef95 --- /dev/null +++ b/packages/plugins/plugin-triage/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +});