Files
PaperClipAI/server/src/__tests__/agent-instructions-service.test.ts
T
DottaandPaperclip af5000fce4 fix(server): guard managed instruction rebuilds
Centralize stock drift detection, staged swaps, and explicit reset backups across every managed-instructions caller.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-07 08:18:32 +00:00

522 lines
22 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { agentInstructionsService } from "../services/agent-instructions.js";
type TestAgent = {
id: string;
companyId: string;
name: string;
adapterConfig: Record<string, unknown>;
};
async function makeTempDir(prefix: string) {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
function makeAgent(adapterConfig: Record<string, unknown>): TestAgent {
return {
id: "agent-1",
companyId: "company-1",
name: "Agent 1",
adapterConfig,
};
}
describe("agent instructions service", () => {
const originalPaperclipHome = process.env.PAPERCLIP_HOME;
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const cleanupDirs = new Set<string>();
afterEach(async () => {
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId;
await Promise.all([...cleanupDirs].map(async (dir) => {
await fs.rm(dir, { recursive: true, force: true });
cleanupDirs.delete(dir);
}));
});
it("copies the existing bundle into the managed root when switching to managed mode", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const externalRoot = await makeTempDir("paperclip-agent-instructions-external-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(externalRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8");
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "external",
instructionsRootPath: externalRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(externalRoot, "AGENTS.md"),
});
const result = await svc.updateBundle(agent, { mode: "managed" });
expect(result.bundle.mode).toBe("managed");
expect(result.bundle.managedRootPath).toBe(
path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
),
);
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md", "docs/TOOLS.md"]);
await expect(fs.readFile(path.join(result.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# External Agent\n");
await expect(fs.readFile(path.join(result.bundle.managedRootPath, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
});
it("creates the target entry file when switching to a new external root", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
const externalRoot = await makeTempDir("paperclip-agent-instructions-new-external-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(externalRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
});
const result = await svc.updateBundle(agent, {
mode: "external",
rootPath: externalRoot,
entryFile: "docs/AGENTS.md",
});
expect(result.bundle.mode).toBe("external");
expect(result.bundle.rootPath).toBe(externalRoot);
await expect(fs.readFile(path.join(externalRoot, "docs", "AGENTS.md"), "utf8")).resolves.toBe("# Managed Agent\n");
});
it("filters junk files, dependency bundles, and python caches from bundle listings and exports", async () => {
const externalRoot = await makeTempDir("paperclip-agent-instructions-ignore-");
cleanupDirs.add(externalRoot);
await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8");
await fs.writeFile(path.join(externalRoot, ".gitignore"), "node_modules/\n", "utf8");
await fs.writeFile(path.join(externalRoot, ".DS_Store"), "junk", "utf8");
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
await fs.writeFile(path.join(externalRoot, "docs", "module.pyc"), "compiled", "utf8");
await fs.writeFile(path.join(externalRoot, "docs", "._TOOLS.md"), "appledouble", "utf8");
await fs.mkdir(path.join(externalRoot, "node_modules", "pkg"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "node_modules", "pkg", "index.js"), "export {};\n", "utf8");
await fs.mkdir(path.join(externalRoot, "python", "__pycache__"), { recursive: true });
await fs.writeFile(
path.join(externalRoot, "python", "__pycache__", "module.cpython-313.pyc"),
"compiled",
"utf8",
);
await fs.mkdir(path.join(externalRoot, ".pytest_cache"), { recursive: true });
await fs.writeFile(path.join(externalRoot, ".pytest_cache", "README.md"), "cache", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "external",
instructionsRootPath: externalRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(externalRoot, "AGENTS.md"),
});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.files.map((file) => file.path)).toEqual([".gitignore", "AGENTS.md", "docs/TOOLS.md"]);
expect(Object.keys(exported.files).sort((left, right) => left.localeCompare(right))).toEqual([
".gitignore",
"AGENTS.md",
"docs/TOOLS.md",
]);
});
it("recovers a managed bundle from disk when bundle config metadata is missing", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-recover-");
cleanupDirs.add(paperclipHome);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Recovered Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" });
});
it("prefers the managed bundle on disk when managed metadata points at a stale root", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-stale-managed-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-stale-root-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.entryFile).toBe("AGENTS.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
it("heals stale managed metadata when writing bundle files", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-write-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-write-stale-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
});
const result = await svc.writeFile(agent, "docs/TOOLS.md", "## Tools\n");
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
});
await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
});
it("heals stale managed metadata when deleting bundle files", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-delete-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-delete-stale-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
await fs.writeFile(path.join(managedRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsBundleMode: "managed",
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"),
});
const result = await svc.deleteFile(agent, "docs/TOOLS.md");
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
});
await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow();
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
});
it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-");
const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-");
cleanupDirs.add(paperclipHome);
cleanupDirs.add(staleRoot);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const managedRoot = path.join(
paperclipHome,
"instances",
"test-instance",
"companies",
"company-1",
"agents",
"agent-1",
"instructions",
);
await fs.mkdir(managedRoot, { recursive: true });
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8");
const svc = agentInstructionsService();
const agent = makeAgent({
instructionsRootPath: staleRoot,
instructionsEntryFile: "docs/MISSING.md",
});
const bundle = await svc.getBundle(agent);
const exported = await svc.exportFiles(agent);
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
expect(bundle.entryFile).toBe("AGENTS.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
"Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
it("preserves the whole operator-modified tree during routine materialization", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-drift-");
cleanupDirs.add(paperclipHome);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const svc = agentInstructionsService();
const agent = makeAgent({});
const initial = await svc.materializeManagedBundle(agent, {
"AGENTS.md": "# Stock v1\n",
"docs/STOCK.md": "Stock docs\n",
});
expect(initial.materialization).toMatchObject({ stockStatus: "missing", action: "added" });
const managedRoot = initial.bundle.managedRootPath;
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Operator edit\n", "utf8");
await fs.writeFile(path.join(managedRoot, "OPERATOR.md"), "Operator-added file\n", "utf8");
const rebuilt = await svc.materializeManagedBundle(
{ ...agent, adapterConfig: initial.adapterConfig },
{
"AGENTS.md": "# Stock v2\n",
"docs/STOCK.md": "Updated stock docs\n",
},
);
expect(rebuilt.materialization).toMatchObject({
stockStatus: "operator_modified",
action: "skipped",
skipped: ["AGENTS.md", "OPERATOR.md", "docs/STOCK.md"],
backupPath: null,
});
expect(rebuilt.adapterConfig.instructionsStockHash).toBe(initial.adapterConfig.instructionsStockHash);
await expect(fs.readFile(path.join(managedRoot, "AGENTS.md"), "utf8")).resolves.toBe("# Operator edit\n");
await expect(fs.readFile(path.join(managedRoot, "OPERATOR.md"), "utf8")).resolves.toBe("Operator-added file\n");
await expect(fs.readFile(path.join(managedRoot, "docs", "STOCK.md"), "utf8")).resolves.toBe("Stock docs\n");
});
it("atomically advances a clean stock tree and its recorded hash", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-update-");
cleanupDirs.add(paperclipHome);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const svc = agentInstructionsService();
const agent = makeAgent({});
const initial = await svc.materializeManagedBundle(agent, {
"AGENTS.md": "# Stock v1\n",
"docs/OLD.md": "Old\n",
});
const updated = await svc.materializeManagedBundle(
{ ...agent, adapterConfig: initial.adapterConfig },
{
"AGENTS.md": "# Stock v2\n",
"docs/NEW.md": "New\n",
},
);
expect(updated.materialization).toMatchObject({
stockStatus: "stock_update_available",
action: "updated",
added: ["docs/NEW.md"],
removed: ["docs/OLD.md"],
updated: ["AGENTS.md"],
backupPath: null,
});
expect(updated.adapterConfig.instructionsStockHash).toBe(updated.materialization.stockHash);
expect(updated.adapterConfig.instructionsStockHash).not.toBe(initial.adapterConfig.instructionsStockHash);
await expect(fs.readFile(path.join(updated.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# Stock v2\n");
await expect(fs.readFile(path.join(updated.bundle.managedRootPath, "docs", "NEW.md"), "utf8")).resolves.toBe("New\n");
await expect(fs.stat(path.join(updated.bundle.managedRootPath, "docs", "OLD.md"))).rejects.toThrow();
});
it("canonicalizes line endings and ignores transient files when classifying drift", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-canonical-");
cleanupDirs.add(paperclipHome);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const svc = agentInstructionsService();
const agent = makeAgent({});
const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock\nLine\n" });
const managedRoot = initial.bundle.managedRootPath;
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Stock\r\nLine\r\n", "utf8");
await fs.writeFile(path.join(managedRoot, ".DS_Store"), "transient", "utf8");
const unchanged = await svc.materializeManagedBundle(
{ ...agent, adapterConfig: initial.adapterConfig },
{ "AGENTS.md": "# Stock\nLine\n" },
);
expect(unchanged.materialization).toMatchObject({ stockStatus: "stock_current", action: "unchanged" });
await expect(fs.readFile(path.join(managedRoot, "AGENTS.md"), "utf8")).resolves.toBe("# Stock\r\nLine\r\n");
await expect(fs.readFile(path.join(managedRoot, ".DS_Store"), "utf8")).resolves.toBe("transient");
});
it("rolls the live tree back when the staged atomic swap fails", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-rollback-");
cleanupDirs.add(paperclipHome);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const svc = agentInstructionsService();
const agent = makeAgent({});
const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock v1\n" });
const originalRename = fs.rename.bind(fs);
const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (source, destination) => {
if (path.basename(String(source)) === "staged") throw new Error("simulated swap failure");
return originalRename(source, destination);
});
try {
await expect(svc.materializeManagedBundle(
{ ...agent, adapterConfig: initial.adapterConfig },
{ "AGENTS.md": "# Stock v2\n" },
)).rejects.toThrow("simulated swap failure");
} finally {
renameSpy.mockRestore();
}
await expect(fs.readFile(path.join(initial.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# Stock v1\n");
});
it("backs up operator state before an explicitly requested force reset", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-reset-");
cleanupDirs.add(paperclipHome);
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
const svc = agentInstructionsService();
const agent = makeAgent({});
const initial = await svc.materializeManagedBundle(agent, { "AGENTS.md": "# Stock v1\n" });
const managedRoot = initial.bundle.managedRootPath;
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Operator edit\n", "utf8");
await fs.writeFile(path.join(managedRoot, "OPERATOR.md"), "Keep me\n", "utf8");
const reset = await svc.forceResetManagedBundle(
{ ...agent, adapterConfig: initial.adapterConfig },
{ "AGENTS.md": "# Stock v2\n" },
);
expect(reset.materialization).toMatchObject({
stockStatus: "operator_modified",
action: "reset",
backupPath: `${managedRoot}.backup-1`,
backedUp: ["AGENTS.md", "OPERATOR.md"],
});
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "AGENTS.md"), "utf8")).resolves.toBe("# Operator edit\n");
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "OPERATOR.md"), "utf8")).resolves.toBe("Keep me\n");
await expect(fs.readFile(path.join(managedRoot, "AGENTS.md"), "utf8")).resolves.toBe("# Stock v2\n");
await expect(fs.stat(path.join(managedRoot, "OPERATOR.md"))).rejects.toThrow();
await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Second operator edit\n", "utf8");
const secondReset = await svc.forceResetManagedBundle(
{ ...agent, adapterConfig: reset.adapterConfig },
{ "AGENTS.md": "# Stock v3\n" },
);
expect(secondReset.materialization.backupPath).toBe(`${managedRoot}.backup-2`);
await expect(fs.readFile(path.join(`${managedRoot}.backup-1`, "AGENTS.md"), "utf8")).resolves.toBe("# Operator edit\n");
await expect(fs.readFile(path.join(`${managedRoot}.backup-2`, "AGENTS.md"), "utf8")).resolves.toBe(
"# Second operator edit\n",
);
});
});