Improve instructions bundle mode switching
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -103,6 +103,7 @@ describe("agent instructions bundle routes", () => {
|
||||
companyId: "company-1",
|
||||
mode: "managed",
|
||||
rootPath: "/tmp/agent-1",
|
||||
managedRootPath: "/tmp/agent-1",
|
||||
entryFile: "AGENTS.md",
|
||||
resolvedEntryPath: "/tmp/agent-1/AGENTS.md",
|
||||
editable: true,
|
||||
@@ -161,6 +162,7 @@ describe("agent instructions bundle routes", () => {
|
||||
expect(res.body).toMatchObject({
|
||||
mode: "managed",
|
||||
rootPath: "/tmp/agent-1",
|
||||
managedRootPath: "/tmp/agent-1",
|
||||
entryFile: "AGENTS.md",
|
||||
});
|
||||
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
|
||||
|
||||
123
server/src/__tests__/agent-instructions-service.test.ts
Normal file
123
server/src/__tests__/agent-instructions-service.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } 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");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ type AgentInstructionsBundle = {
|
||||
companyId: string;
|
||||
mode: BundleMode | null;
|
||||
rootPath: string | null;
|
||||
managedRootPath: string;
|
||||
entryFile: string;
|
||||
resolvedEntryPath: string | null;
|
||||
editable: boolean;
|
||||
@@ -266,6 +267,7 @@ function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructions
|
||||
companyId: agent.companyId,
|
||||
mode: state.mode,
|
||||
rootPath: state.rootPath,
|
||||
managedRootPath: resolveManagedInstructionsRoot(agent),
|
||||
entryFile: state.entryFile,
|
||||
resolvedEntryPath: state.resolvedEntryPath,
|
||||
editable: Boolean(state.rootPath),
|
||||
@@ -299,6 +301,21 @@ function applyBundleConfig(
|
||||
return next;
|
||||
}
|
||||
|
||||
async function writeBundleFiles(
|
||||
rootPath: string,
|
||||
files: Record<string, string>,
|
||||
options?: { overwriteExisting?: boolean },
|
||||
) {
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const normalizedPath = normalizeRelativeFilePath(relativePath);
|
||||
const absolutePath = resolvePathWithinRoot(rootPath, normalizedPath);
|
||||
const existingStat = await statIfExists(absolutePath);
|
||||
if (existingStat?.isFile() && !options?.overwriteExisting) continue;
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export function syncInstructionsBundleConfigFromFilePath(
|
||||
agent: AgentLike,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
@@ -440,6 +457,17 @@ export function agentInstructionsService() {
|
||||
|
||||
await fs.mkdir(nextRootPath, { recursive: true });
|
||||
|
||||
const existingFiles = await listFilesRecursive(nextRootPath);
|
||||
const exported = await exportFiles(agent);
|
||||
if (existingFiles.length === 0) {
|
||||
await writeBundleFiles(nextRootPath, exported.files);
|
||||
}
|
||||
const refreshedFiles = existingFiles.length === 0 ? await listFilesRecursive(nextRootPath) : existingFiles;
|
||||
if (!refreshedFiles.includes(nextEntryFile)) {
|
||||
const nextEntryContent = exported.files[nextEntryFile] ?? exported.files[exported.entryFile] ?? "";
|
||||
await writeBundleFiles(nextRootPath, { [nextEntryFile]: nextEntryContent });
|
||||
}
|
||||
|
||||
const nextConfig = applyBundleConfig(state.config, {
|
||||
mode: nextMode,
|
||||
rootPath: nextRootPath,
|
||||
|
||||
Reference in New Issue
Block a user