Improve instructions bundle mode switching

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-18 08:10:36 -05:00
parent 5252568825
commit 154a4a7ac1
5 changed files with 259 additions and 41 deletions

View File

@@ -31,6 +31,7 @@ export interface AgentInstructionsBundle {
companyId: string; companyId: string;
mode: AgentInstructionsBundleMode | null; mode: AgentInstructionsBundleMode | null;
rootPath: string | null; rootPath: string | null;
managedRootPath: string;
entryFile: string; entryFile: string;
resolvedEntryPath: string | null; resolvedEntryPath: string | null;
editable: boolean; editable: boolean;

View File

@@ -103,6 +103,7 @@ describe("agent instructions bundle routes", () => {
companyId: "company-1", companyId: "company-1",
mode: "managed", mode: "managed",
rootPath: "/tmp/agent-1", rootPath: "/tmp/agent-1",
managedRootPath: "/tmp/agent-1",
entryFile: "AGENTS.md", entryFile: "AGENTS.md",
resolvedEntryPath: "/tmp/agent-1/AGENTS.md", resolvedEntryPath: "/tmp/agent-1/AGENTS.md",
editable: true, editable: true,
@@ -161,6 +162,7 @@ describe("agent instructions bundle routes", () => {
expect(res.body).toMatchObject({ expect(res.body).toMatchObject({
mode: "managed", mode: "managed",
rootPath: "/tmp/agent-1", rootPath: "/tmp/agent-1",
managedRootPath: "/tmp/agent-1",
entryFile: "AGENTS.md", entryFile: "AGENTS.md",
}); });
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled(); expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();

View 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");
});
});

View File

@@ -42,6 +42,7 @@ type AgentInstructionsBundle = {
companyId: string; companyId: string;
mode: BundleMode | null; mode: BundleMode | null;
rootPath: string | null; rootPath: string | null;
managedRootPath: string;
entryFile: string; entryFile: string;
resolvedEntryPath: string | null; resolvedEntryPath: string | null;
editable: boolean; editable: boolean;
@@ -266,6 +267,7 @@ function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructions
companyId: agent.companyId, companyId: agent.companyId,
mode: state.mode, mode: state.mode,
rootPath: state.rootPath, rootPath: state.rootPath,
managedRootPath: resolveManagedInstructionsRoot(agent),
entryFile: state.entryFile, entryFile: state.entryFile,
resolvedEntryPath: state.resolvedEntryPath, resolvedEntryPath: state.resolvedEntryPath,
editable: Boolean(state.rootPath), editable: Boolean(state.rootPath),
@@ -299,6 +301,21 @@ function applyBundleConfig(
return next; 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( export function syncInstructionsBundleConfigFromFilePath(
agent: AgentLike, agent: AgentLike,
adapterConfig: Record<string, unknown>, adapterConfig: Record<string, unknown>,
@@ -440,6 +457,17 @@ export function agentInstructionsService() {
await fs.mkdir(nextRootPath, { recursive: true }); 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, { const nextConfig = applyBundleConfig(state.config, {
mode: nextMode, mode: nextMode,
rootPath: nextRootPath, rootPath: nextRootPath,

View File

@@ -1513,6 +1513,11 @@ function PromptsTab({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [awaitingRefresh, setAwaitingRefresh] = useState(false); const [awaitingRefresh, setAwaitingRefresh] = useState(false);
const lastFileVersionRef = useRef<string | null>(null); const lastFileVersionRef = useRef<string | null>(null);
const externalBundleRef = useRef<{
rootPath: string;
entryFile: string;
selectedFile: string;
} | null>(null);
const isLocal = const isLocal =
agent.adapterType === "claude_local" || agent.adapterType === "claude_local" ||
@@ -1528,23 +1533,35 @@ function PromptsTab({
enabled: Boolean(companyId && isLocal), enabled: Boolean(companyId && isLocal),
}); });
const currentMode = bundleDraft?.mode ?? bundle?.mode ?? "managed"; const persistedMode = bundle?.mode ?? "managed";
const persistedRootPath = persistedMode === "managed"
? (bundle?.managedRootPath ?? bundle?.rootPath ?? "")
: (bundle?.rootPath ?? "");
const currentMode = bundleDraft?.mode ?? persistedMode;
const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md"; const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
const currentRootPath = bundleDraft?.rootPath ?? bundle?.rootPath ?? ""; const currentRootPath = bundleDraft?.rootPath ?? persistedRootPath;
const fileOptions = useMemo( const fileOptions = useMemo(
() => bundle?.files.map((file) => file.path) ?? [], () => bundle?.files.map((file) => file.path) ?? [],
[bundle], [bundle],
); );
const bundleMatchesDraft = Boolean(
bundle &&
currentMode === persistedMode &&
currentEntryFile === bundle.entryFile &&
currentRootPath === persistedRootPath,
);
const visibleFilePaths = useMemo( const visibleFilePaths = useMemo(
() => [...new Set([currentEntryFile, ...fileOptions])], () => bundleMatchesDraft
[currentEntryFile, fileOptions], ? [...new Set([currentEntryFile, ...fileOptions])]
: [currentEntryFile],
[bundleMatchesDraft, currentEntryFile, fileOptions],
); );
const fileTree = useMemo( const fileTree = useMemo(
() => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))), () => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))),
[visibleFilePaths], [visibleFilePaths],
); );
const selectedOrEntryFile = selectedFile || currentEntryFile; const selectedOrEntryFile = selectedFile || currentEntryFile;
const selectedFileExists = fileOptions.includes(selectedOrEntryFile); const selectedFileExists = bundleMatchesDraft && fileOptions.includes(selectedOrEntryFile);
const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null; const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null;
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({ const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
@@ -1603,6 +1620,10 @@ function PromptsTab({
useEffect(() => { useEffect(() => {
if (!bundle) return; if (!bundle) return;
if (!bundleMatchesDraft) {
if (selectedFile !== currentEntryFile) setSelectedFile(currentEntryFile);
return;
}
const availablePaths = bundle.files.map((file) => file.path); const availablePaths = bundle.files.map((file) => file.path);
if (availablePaths.length === 0) { if (availablePaths.length === 0) {
if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile); if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile);
@@ -1611,7 +1632,7 @@ function PromptsTab({
if (!availablePaths.includes(selectedFile)) { if (!availablePaths.includes(selectedFile)) {
setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!); setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!);
} }
}, [bundle, selectedFile]); }, [bundle, bundleMatchesDraft, currentEntryFile, selectedFile]);
useEffect(() => { useEffect(() => {
const nextExpanded = new Set<string>(); const nextExpanded = new Set<string>();
@@ -1627,7 +1648,9 @@ function PromptsTab({
}, [visibleFilePaths]); }, [visibleFilePaths]);
useEffect(() => { useEffect(() => {
const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`; const versionKey = selectedFileExists && selectedFileDetail
? `${selectedFileDetail.path}:${selectedFileDetail.content}`
: `draft:${currentMode}:${currentRootPath}:${selectedOrEntryFile}`;
if (awaitingRefresh) { if (awaitingRefresh) {
setAwaitingRefresh(false); setAwaitingRefresh(false);
setBundleDraft(null); setBundleDraft(null);
@@ -1639,27 +1662,36 @@ function PromptsTab({
setDraft(null); setDraft(null);
lastFileVersionRef.current = versionKey; lastFileVersionRef.current = versionKey;
} }
}, [awaitingRefresh, selectedFileDetail, selectedOrEntryFile]); }, [awaitingRefresh, currentMode, currentRootPath, selectedFileDetail, selectedFileExists, selectedOrEntryFile]);
useEffect(() => { useEffect(() => {
if (!bundle) return; if (!bundle) return;
setBundleDraft((current) => { setBundleDraft((current) => {
if (current) return current; if (current) return current;
return { return {
mode: bundle.mode ?? "managed", mode: persistedMode,
rootPath: bundle.rootPath ?? "", rootPath: persistedRootPath,
entryFile: bundle.entryFile, entryFile: bundle.entryFile,
}; };
}); });
}, [bundle]); }, [bundle, persistedMode, persistedRootPath]);
useEffect(() => {
if (!bundle || currentMode !== "external") return;
externalBundleRef.current = {
rootPath: currentRootPath,
entryFile: currentEntryFile,
selectedFile: selectedOrEntryFile,
};
}, [bundle, currentEntryFile, currentMode, currentRootPath, selectedOrEntryFile]);
const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : ""; const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : "";
const displayValue = draft ?? currentContent; const displayValue = draft ?? currentContent;
const bundleDirty = Boolean( const bundleDirty = Boolean(
bundleDraft && bundleDraft &&
( (
bundleDraft.mode !== (bundle?.mode ?? "managed") || bundleDraft.mode !== persistedMode ||
bundleDraft.rootPath !== (bundle?.rootPath ?? "") || bundleDraft.rootPath !== persistedRootPath ||
bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md") bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md")
), ),
); );
@@ -1710,13 +1742,13 @@ function PromptsTab({
setDraft(null); setDraft(null);
if (bundle) { if (bundle) {
setBundleDraft({ setBundleDraft({
mode: bundle.mode ?? "managed", mode: persistedMode,
rootPath: bundle.rootPath ?? "", rootPath: persistedRootPath,
entryFile: bundle.entryFile, entryFile: bundle.entryFile,
}); });
} }
} : null); } : null);
}, [bundle, isDirty, onCancelActionChange]); }, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath]);
const handleSeparatorDrag = useCallback((event: React.MouseEvent) => { const handleSeparatorDrag = useCallback((event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
@@ -1754,7 +1786,7 @@ function PromptsTab({
} }
return ( return (
<div className="max-w-6xl space-y-4"> <div className="max-w-6xl space-y-6">
{(bundle?.warnings ?? []).length > 0 && ( {(bundle?.warnings ?? []).length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{(bundle?.warnings ?? []).map((warning) => ( {(bundle?.warnings ?? []).map((warning) => (
@@ -1907,7 +1939,7 @@ function PromptsTab({
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" /> <ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
Advanced Advanced
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="pt-3 space-y-3"> <CollapsibleContent className="pt-4 pb-4 space-y-4">
<TooltipProvider> <TooltipProvider>
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1"> <span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
@@ -1926,11 +1958,22 @@ function PromptsTab({
type="button" type="button"
size="sm" size="sm"
variant={currentMode === "managed" ? "default" : "outline"} variant={currentMode === "managed" ? "default" : "outline"}
onClick={() => setBundleDraft((current) => ({ onClick={() => {
mode: "managed", if (currentMode === "external") {
rootPath: current?.rootPath ?? bundle?.rootPath ?? "", externalBundleRef.current = {
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md", rootPath: currentRootPath,
}))} entryFile: currentEntryFile,
selectedFile: selectedOrEntryFile,
};
}
const nextEntryFile = currentEntryFile || "AGENTS.md";
setBundleDraft({
mode: "managed",
rootPath: bundle?.managedRootPath ?? currentRootPath,
entryFile: nextEntryFile,
});
setSelectedFile(nextEntryFile);
}}
> >
Managed Managed
</Button> </Button>
@@ -1938,17 +1981,22 @@ function PromptsTab({
type="button" type="button"
size="sm" size="sm"
variant={currentMode === "external" ? "default" : "outline"} variant={currentMode === "external" ? "default" : "outline"}
onClick={() => setBundleDraft((current) => ({ onClick={() => {
mode: "external", const externalBundle = externalBundleRef.current;
rootPath: current?.rootPath ?? bundle?.rootPath ?? "", const nextEntryFile = externalBundle?.entryFile ?? currentEntryFile ?? "AGENTS.md";
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md", setBundleDraft({
}))} mode: "external",
rootPath: externalBundle?.rootPath ?? (bundle?.mode === "external" ? (bundle.rootPath ?? "") : ""),
entryFile: nextEntryFile,
});
setSelectedFile(externalBundle?.selectedFile ?? nextEntryFile);
}}
> >
External External
</Button> </Button>
</div> </div>
</label> </label>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1"> <span className="text-xs font-medium text-muted-foreground flex items-center gap-1">
Root path Root path
@@ -1963,7 +2011,7 @@ function PromptsTab({
</span> </span>
{currentMode === "managed" ? ( {currentMode === "managed" ? (
<div className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground">
<span className="truncate">{currentRootPath || "(managed)"}</span> <span className="min-w-0 break-all">{currentRootPath || "(managed)"}</span>
{currentRootPath && ( {currentRootPath && (
<CopyText text={currentRootPath} className="shrink-0"> <CopyText text={currentRootPath} className="shrink-0">
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
@@ -1974,11 +2022,19 @@ function PromptsTab({
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Input <Input
value={currentRootPath} value={currentRootPath}
onChange={(event) => setBundleDraft((current) => ({ onChange={(event) => {
mode: current?.mode ?? bundle?.mode ?? "managed", const nextRootPath = event.target.value;
rootPath: event.target.value, externalBundleRef.current = {
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md", rootPath: nextRootPath,
}))} entryFile: currentEntryFile,
selectedFile: selectedOrEntryFile,
};
setBundleDraft({
mode: "external",
rootPath: nextRootPath,
entryFile: currentEntryFile,
});
}}
className="font-mono text-sm" className="font-mono text-sm"
placeholder="/absolute/path/to/agent/prompts" placeholder="/absolute/path/to/agent/prompts"
/> />
@@ -2006,14 +2062,22 @@ function PromptsTab({
value={currentEntryFile} value={currentEntryFile}
onChange={(event) => { onChange={(event) => {
const nextEntryFile = event.target.value || "AGENTS.md"; const nextEntryFile = event.target.value || "AGENTS.md";
if (selectedOrEntryFile === currentEntryFile) { const nextSelectedFile = selectedOrEntryFile === currentEntryFile
setSelectedFile(nextEntryFile); ? nextEntryFile
: selectedOrEntryFile;
if (currentMode === "external") {
externalBundleRef.current = {
rootPath: currentRootPath,
entryFile: nextEntryFile,
selectedFile: nextSelectedFile,
};
} }
setBundleDraft((current) => ({ if (selectedOrEntryFile === currentEntryFile) setSelectedFile(nextEntryFile);
mode: current?.mode ?? bundle?.mode ?? "managed", setBundleDraft({
rootPath: current?.rootPath ?? bundle?.rootPath ?? "", mode: currentMode,
rootPath: currentRootPath,
entryFile: nextEntryFile, entryFile: nextEntryFile,
})); });
}} }}
className="font-mono text-sm" className="font-mono text-sm"
/> />