paperclip/server/src/__tests__/paperclip-skill-utils.test.ts

147 lines
7.4 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
} from "@paperclipai/adapter-utils/server-utils";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("paperclip skill utils", () => {
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("lists bundled runtime skills from ./skills without pulling in .agents/skills", async () => {
const root = await makeTempDir("paperclip-skill-roots-");
cleanupDirs.add(root);
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
await fs.mkdir(moduleDir, { recursive: true });
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
await fs.mkdir(path.join(root, "skills", "paperclip-create-agent"), { recursive: true });
await fs.mkdir(path.join(root, ".agents", "skills", "diagnose-why-work-stopped"), { recursive: true });
await fs.mkdir(path.join(root, ".agents", "skills", "paperclip-create-plugin"), { recursive: true });
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
await fs.mkdir(path.join(root, ".agents", "skills", "terminal-bench-loop"), { recursive: true });
const entries = await listPaperclipSkillEntries(moduleDir);
expect(entries.map((entry) => entry.key)).toEqual([
"paperclipai/paperclip/paperclip",
"paperclipai/paperclip/paperclip-create-agent",
]);
expect(entries.map((entry) => entry.runtimeName)).toEqual([
"paperclip",
"paperclip-create-agent",
]);
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
expect(entries[1]?.source).toBe(path.join(root, "skills", "paperclip-create-agent"));
});
it("documents artifact uploads in the installed Paperclip skill", async () => {
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
const referenceBody = await fs.readFile(path.resolve("skills/paperclip/references/artifacts.md"), "utf8");
expect(skillBody).toContain("Generated Artifacts and Work Products");
expect(skillBody).toContain("references/artifacts.md");
expect(skillBody).not.toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments");
expect(referenceBody).toContain("Generated Artifacts and Work Products");
expect(referenceBody).toContain("scripts/paperclip-upload-artifact.sh");
expect(referenceBody).toContain("POST");
expect(referenceBody).toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments");
expect(referenceBody).toContain("/api/issues/$PAPERCLIP_TASK_ID/work-products");
await expect(
fs.access(path.resolve("skills/paperclip/scripts/paperclip-upload-artifact.sh")),
).resolves.toBeUndefined();
await expect(fs.access(path.resolve("scripts/paperclip-upload-artifact.sh"))).rejects.toThrow();
});
it("documents governed agent interaction resolution invariants", async () => {
const apiReference = await fs.readFile(path.resolve("skills/paperclip/references/api-reference.md"), "utf8");
const issueDocs = await fs.readFile(path.resolve("docs/api/issues.md"), "utf8");
for (const body of [apiReference, issueDocs]) {
expect(body).toContain('resolverPolicy: "anyone" | "not_creator" | "human_only"');
expect(body).toContain("requestedResolverPolicy");
expect(body).toContain("effectiveResolverPolicy");
expect(body).toContain("toolAction");
expect(body).toContain("watchdog");
expect(body).toContain("low-trust");
expect(body).toContain("addresseeAgentId");
expect(body).toContain("interaction_pending");
expect(body).toContain("attention feed");
}
});
it("uses the authoritative PATCH response to confirm monitor scheduling", async () => {
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
expect(skillBody).toContain("Use that request's default full response");
expect(skillBody).toContain("do not issue a confirming GET");
expect(skillBody).toContain("`monitorNextCheckAt` is non-null");
expect(skillBody).toContain("`assigneeAgentId` is set");
expect(skillBody).toContain("`assigneeUserId` is null");
});
it("requires issue-update writes to be verified, not inferred", async () => {
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
expect(skillBody).toContain("Verify writes — never infer them");
expect(skillBody).toContain("An empty response body means the write FAILED");
expect(skillBody).toContain("Never pipe a disposition write through `head`/`tail`");
// The helper's verification behavior (HTTP status parsing, retry
// classification, attempt bound, exit codes) is exercised end-to-end in
// paperclip-issue-update-helper.test.ts against a live local server.
});
it("keeps the create-issue-interaction-ui guide as a maintainer-only skill", async () => {
const skillPath = path.resolve(".agents/skills/create-issue-interaction-ui/SKILL.md");
const skillBody = await fs.readFile(skillPath, "utf8");
const normalizedSkillBody = skillBody.replace(/\s+/g, " ");
const normalizedLowerSkillBody = normalizedSkillBody.toLowerCase();
expect(skillBody).toContain("name: create-issue-interaction-ui");
expect(normalizedLowerSkillBody).toContain("developer/maintainer skill");
expect(normalizedLowerSkillBody).toContain(
"not the operational agents that run inside a deployed paperclip company",
);
expect(skillBody).toContain("packages/shared/src/constants.ts");
expect(skillBody).toContain("server/src/services/issue-thread-interactions.ts");
expect(skillBody).toContain("ui/src/components/IssueThreadInteractionCard.tsx");
expect(skillBody).toContain("packages/plugins/sdk/src/testing.ts");
await expect(fs.access(path.resolve("skills/create-issue-interaction-ui/SKILL.md"))).rejects.toThrow();
});
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
const root = await makeTempDir("paperclip-skill-cleanup-");
cleanupDirs.add(root);
const skillsHome = path.join(root, "skills-home");
const runtimeSkill = path.join(root, "skills", "paperclip");
const customSkill = path.join(root, "custom", "release-notes");
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
await fs.mkdir(skillsHome, { recursive: true });
await fs.mkdir(runtimeSkill, { recursive: true });
await fs.mkdir(customSkill, { recursive: true });
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
expect(removed).toEqual(["release"]);
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
});
});