Refine external instructions bundle handling

Keep existing instructionsFilePath agents in external-bundle mode during edits, expose legacy promptTemplate as a deprecated virtual file, and reuse the shared PackageFileTree component in the Prompts view.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-17 16:15:45 -05:00
parent 4fdcfe5515
commit 9d452eb120
5 changed files with 175 additions and 57 deletions

View File

@@ -17,11 +17,13 @@ export interface AgentInstructionsFileSummary {
language: string; language: string;
markdown: boolean; markdown: boolean;
isEntryFile: boolean; isEntryFile: boolean;
editable: boolean;
deprecated: boolean;
virtual: boolean;
} }
export interface AgentInstructionsFileDetail extends AgentInstructionsFileSummary { export interface AgentInstructionsFileDetail extends AgentInstructionsFileSummary {
content: string; content: string;
editable: boolean;
} }
export interface AgentInstructionsBundle { export interface AgentInstructionsBundle {

View File

@@ -109,7 +109,16 @@ describe("agent instructions bundle routes", () => {
warnings: [], warnings: [],
legacyPromptTemplateActive: false, legacyPromptTemplateActive: false,
legacyBootstrapPromptTemplateActive: false, legacyBootstrapPromptTemplateActive: false,
files: [{ path: "AGENTS.md", size: 12, language: "markdown", markdown: true, isEntryFile: true }], files: [{
path: "AGENTS.md",
size: 12,
language: "markdown",
markdown: true,
isEntryFile: true,
editable: true,
deprecated: false,
virtual: false,
}],
}); });
mockAgentInstructionsService.readFile.mockResolvedValue({ mockAgentInstructionsService.readFile.mockResolvedValue({
path: "AGENTS.md", path: "AGENTS.md",
@@ -118,6 +127,8 @@ describe("agent instructions bundle routes", () => {
markdown: true, markdown: true,
isEntryFile: true, isEntryFile: true,
editable: true, editable: true,
deprecated: false,
virtual: false,
content: "# Agent\n", content: "# Agent\n",
}); });
mockAgentInstructionsService.writeFile.mockResolvedValue({ mockAgentInstructionsService.writeFile.mockResolvedValue({
@@ -129,6 +140,8 @@ describe("agent instructions bundle routes", () => {
markdown: true, markdown: true,
isEntryFile: true, isEntryFile: true,
editable: true, editable: true,
deprecated: false,
virtual: false,
content: "# Updated Agent\n", content: "# Updated Agent\n",
}, },
adapterConfig: { adapterConfig: {

View File

@@ -10,6 +10,7 @@ const ENTRY_KEY = "instructionsEntryFile";
const FILE_KEY = "instructionsFilePath"; const FILE_KEY = "instructionsFilePath";
const PROMPT_KEY = "promptTemplate"; const PROMPT_KEY = "promptTemplate";
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate"; const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md";
type BundleMode = "managed" | "external"; type BundleMode = "managed" | "external";
@@ -26,6 +27,9 @@ type AgentInstructionsFileSummary = {
language: string; language: string;
markdown: boolean; markdown: boolean;
isEntryFile: boolean; isEntryFile: boolean;
editable: boolean;
deprecated: boolean;
virtual: boolean;
}; };
type AgentInstructionsFileDetail = AgentInstructionsFileSummary & { type AgentInstructionsFileDetail = AgentInstructionsFileSummary & {
@@ -171,6 +175,9 @@ async function readFileSummary(rootPath: string, relativePath: string, entryFile
language: inferLanguage(relativePath), language: inferLanguage(relativePath),
markdown: isMarkdown(relativePath), markdown: isMarkdown(relativePath),
isEntryFile: relativePath === entryFile, isEntryFile: relativePath === entryFile,
editable: true,
deprecated: false,
virtual: false,
}; };
} }
@@ -239,6 +246,21 @@ function deriveBundleState(agent: AgentLike): BundleState {
} }
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle { function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
const nextFiles = [...files];
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
const legacyPromptTemplate = asString(state.config[PROMPT_KEY]) ?? "";
nextFiles.push({
path: LEGACY_PROMPT_TEMPLATE_PATH,
size: legacyPromptTemplate.length,
language: "markdown",
markdown: true,
isEntryFile: false,
editable: true,
deprecated: true,
virtual: true,
});
}
nextFiles.sort((left, right) => left.path.localeCompare(right.path));
return { return {
agentId: agent.id, agentId: agent.id,
companyId: agent.companyId, companyId: agent.companyId,
@@ -250,7 +272,7 @@ function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructions
warnings: state.warnings, warnings: state.warnings,
legacyPromptTemplateActive: state.legacyPromptTemplateActive, legacyPromptTemplateActive: state.legacyPromptTemplateActive,
legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive, legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive,
files, files: nextFiles,
}; };
} }
@@ -317,6 +339,21 @@ export function agentInstructionsService() {
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> { async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
const state = deriveBundleState(agent); const state = deriveBundleState(agent);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const content = asString(state.config[PROMPT_KEY]);
if (content === null) throw notFound("Instructions file not found");
return {
path: LEGACY_PROMPT_TEMPLATE_PATH,
size: content.length,
language: "markdown",
markdown: true,
isEntryFile: false,
editable: true,
deprecated: true,
virtual: true,
content,
};
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured"); if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath); const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath);
const [content, stat] = await Promise.all([ const [content, stat] = await Promise.all([
@@ -331,12 +368,14 @@ export function agentInstructionsService() {
language: inferLanguage(normalizedPath), language: inferLanguage(normalizedPath),
markdown: isMarkdown(normalizedPath), markdown: isMarkdown(normalizedPath),
isEntryFile: normalizedPath === state.entryFile, isEntryFile: normalizedPath === state.entryFile,
content,
editable: true, editable: true,
deprecated: false,
virtual: false,
content,
}; };
} }
async function ensureManagedBundle( async function ensureWritableBundle(
agent: AgentLike, agent: AgentLike,
options?: { clearLegacyPromptTemplate?: boolean }, options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> { ): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
@@ -421,7 +460,21 @@ export function agentInstructionsService() {
file: AgentInstructionsFileDetail; file: AgentInstructionsFileDetail;
adapterConfig: Record<string, unknown>; adapterConfig: Record<string, unknown>;
}> { }> {
const prepared = await ensureManagedBundle(agent, options); const current = deriveBundleState(agent);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const adapterConfig: Record<string, unknown> = {
...current.config,
[PROMPT_KEY]: content,
};
const nextAgent = { ...agent, adapterConfig };
const [bundle, file] = await Promise.all([
getBundle(nextAgent),
readFile(nextAgent, LEGACY_PROMPT_TEMPLATE_PATH),
]);
return { bundle, file, adapterConfig };
}
const prepared = await ensureWritableBundle(agent, options);
const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath); const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8"); await fs.writeFile(absolutePath, content, "utf8");
@@ -438,6 +491,9 @@ export function agentInstructionsService() {
adapterConfig: Record<string, unknown>; adapterConfig: Record<string, unknown>;
}> { }> {
const state = deriveBundleState(agent); const state = deriveBundleState(agent);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured"); if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const normalizedPath = normalizeRelativeFilePath(relativePath); const normalizedPath = normalizeRelativeFilePath(relativePath);
if (normalizedPath === state.entryFile) { if (normalizedPath === state.entryFile) {
@@ -525,7 +581,7 @@ export function agentInstructionsService() {
writeFile, writeFile,
deleteFile, deleteFile,
exportFiles, exportFiles,
ensureManagedBundle, ensureManagedBundle: ensureWritableBundle,
materializeManagedBundle, materializeManagedBundle,
}; };
} }

View File

@@ -175,49 +175,57 @@ export function PackageFileTree({
onToggleCheck, onToggleCheck,
renderFileExtra, renderFileExtra,
fileRowClassName, fileRowClassName,
showCheckboxes = true,
depth = 0, depth = 0,
}: { }: {
nodes: FileTreeNode[]; nodes: FileTreeNode[];
selectedFile: string | null; selectedFile: string | null;
expandedDirs: Set<string>; expandedDirs: Set<string>;
checkedFiles: Set<string>; checkedFiles?: Set<string>;
onToggleDir: (path: string) => void; onToggleDir: (path: string) => void;
onSelectFile: (path: string) => void; onSelectFile: (path: string) => void;
onToggleCheck: (path: string, kind: "file" | "dir") => void; onToggleCheck?: (path: string, kind: "file" | "dir") => void;
/** Optional extra content rendered at the end of each file row (e.g. action badge) */ /** Optional extra content rendered at the end of each file row (e.g. action badge) */
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode; renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
/** Optional additional className for file rows */ /** Optional additional className for file rows */
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined; fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
showCheckboxes?: boolean;
depth?: number; depth?: number;
}) { }) {
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
return ( return (
<div> <div>
{nodes.map((node) => { {nodes.map((node) => {
const expanded = node.kind === "dir" && expandedDirs.has(node.path); const expanded = node.kind === "dir" && expandedDirs.has(node.path);
if (node.kind === "dir") { if (node.kind === "dir") {
const childFiles = collectAllPaths(node.children, "file"); const childFiles = collectAllPaths(node.children, "file");
const allChecked = [...childFiles].every((p) => checkedFiles.has(p)); const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
const someChecked = [...childFiles].some((p) => checkedFiles.has(p)); const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
return ( return (
<div key={node.path}> <div key={node.path}>
<div <div
className={cn( className={cn(
"group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground", showCheckboxes
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
TREE_ROW_HEIGHT_CLASS, TREE_ROW_HEIGHT_CLASS,
)} )}
style={{ style={{
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`, paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}} }}
> >
<label className="flex items-center pl-2"> {showCheckboxes && (
<input <label className="flex items-center pl-2">
type="checkbox" <input
checked={allChecked} type="checkbox"
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }} checked={allChecked}
onChange={() => onToggleCheck(node.path, "dir")} ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
className="mr-2 accent-foreground" onChange={() => onToggleCheck?.(node.path, "dir")}
/> className="mr-2 accent-foreground"
</label> />
</label>
)}
<button <button
type="button" type="button"
className="flex min-w-0 items-center gap-2 py-1 text-left" className="flex min-w-0 items-center gap-2 py-1 text-left"
@@ -249,12 +257,13 @@ export function PackageFileTree({
nodes={node.children} nodes={node.children}
selectedFile={selectedFile} selectedFile={selectedFile}
expandedDirs={expandedDirs} expandedDirs={expandedDirs}
checkedFiles={checkedFiles} checkedFiles={effectiveCheckedFiles}
onToggleDir={onToggleDir} onToggleDir={onToggleDir}
onSelectFile={onSelectFile} onSelectFile={onSelectFile}
onToggleCheck={onToggleCheck} onToggleCheck={onToggleCheck}
renderFileExtra={renderFileExtra} renderFileExtra={renderFileExtra}
fileRowClassName={fileRowClassName} fileRowClassName={fileRowClassName}
showCheckboxes={showCheckboxes}
depth={depth + 1} depth={depth + 1}
/> />
)} )}
@@ -263,7 +272,7 @@ export function PackageFileTree({
} }
const FileIcon = fileIcon(node.name); const FileIcon = fileIcon(node.name);
const checked = checkedFiles.has(node.path); const checked = effectiveCheckedFiles.has(node.path);
const extraClassName = fileRowClassName?.(node, checked); const extraClassName = fileRowClassName?.(node, checked);
return ( return (
<div <div
@@ -278,14 +287,16 @@ export function PackageFileTree({
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`, paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}} }}
> >
<label className="flex items-center pl-2"> {showCheckboxes && (
<input <label className="flex items-center pl-2">
type="checkbox" <input
checked={checked} type="checkbox"
onChange={() => onToggleCheck(node.path, "file")} checked={checked}
className="mr-2 accent-foreground" onChange={() => onToggleCheck?.(node.path, "file")}
/> className="mr-2 accent-foreground"
</label> />
</label>
)}
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left" className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"

View File

@@ -29,6 +29,7 @@ import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
import { ScrollToBottom } from "../components/ScrollToBottom"; import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -1495,6 +1496,7 @@ function PromptsTab({
entryFile: string; entryFile: string;
} | null>(null); } | null>(null);
const [newFilePath, setNewFilePath] = useState(""); const [newFilePath, setNewFilePath] = useState("");
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [awaitingRefresh, setAwaitingRefresh] = useState(false); const [awaitingRefresh, setAwaitingRefresh] = useState(false);
const lastFileVersionRef = useRef<string | null>(null); const lastFileVersionRef = useRef<string | null>(null);
@@ -1516,8 +1518,17 @@ function PromptsTab({
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 ?? bundle?.rootPath ?? "";
const fileOptions = bundle?.files.map((file) => file.path) ?? []; const fileOptions = bundle?.files.map((file) => file.path) ?? [];
const visibleFilePaths = useMemo(
() => [...new Set([currentEntryFile, ...fileOptions])],
[currentEntryFile, fileOptions],
);
const fileTree = useMemo(
() => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))),
[visibleFilePaths],
);
const selectedOrEntryFile = selectedFile || currentEntryFile; const selectedOrEntryFile = selectedFile || currentEntryFile;
const selectedFileExists = fileOptions.includes(selectedOrEntryFile); const selectedFileExists = fileOptions.includes(selectedOrEntryFile);
const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null;
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({ const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile), queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
@@ -1585,6 +1596,19 @@ function PromptsTab({
} }
}, [bundle, selectedFile]); }, [bundle, selectedFile]);
useEffect(() => {
const nextExpanded = new Set<string>();
for (const filePath of visibleFilePaths) {
const parts = filePath.split("/");
let currentPath = "";
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]!;
nextExpanded.add(currentPath);
}
}
setExpandedDirs(nextExpanded);
}, [visibleFilePaths]);
useEffect(() => { useEffect(() => {
const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`; const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`;
if (awaitingRefresh) { if (awaitingRefresh) {
@@ -1708,7 +1732,7 @@ function PromptsTab({
{(bundle?.legacyPromptTemplateActive || bundle?.legacyBootstrapPromptTemplateActive) && ( {(bundle?.legacyPromptTemplateActive || bundle?.legacyBootstrapPromptTemplateActive) && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100"> <div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Legacy inline prompt fields are still active for this agent. The next bundle save will migrate behavior into file-backed instructions and clear those legacy fields. Legacy inline prompt state is still present. `promptTemplate` now appears as a deprecated virtual file entry so it is visible without masquerading as the live `AGENTS.md`.
</div> </div>
)} )}
@@ -1827,30 +1851,40 @@ function PromptsTab({
</Button> </Button>
))} ))}
</div> </div>
<div className="space-y-1"> <PackageFileTree
{[...new Set([currentEntryFile, ...fileOptions])].map((filePath) => { nodes={fileTree}
const file = bundle?.files.find((entry) => entry.path === filePath); selectedFile={selectedOrEntryFile}
return ( expandedDirs={expandedDirs}
<button checkedFiles={new Set()}
key={filePath} onToggleDir={(dirPath) => setExpandedDirs((current) => {
type="button" const next = new Set(current);
className={cn( if (next.has(dirPath)) next.delete(dirPath);
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm", else next.add(dirPath);
filePath === selectedOrEntryFile ? "border-foreground/30 bg-accent/30" : "border-border", return next;
)}
onClick={() => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
>
<span className="truncate font-mono">{filePath}</span>
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
{file?.isEntryFile ? "entry" : file ? `${file.size}b` : "new"}
</span>
</button>
);
})} })}
</div> onSelectFile={(filePath) => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
onToggleCheck={() => {}}
showCheckboxes={false}
renderFileExtra={(node) => {
const file = bundle?.files.find((entry) => entry.path === node.path);
if (!file) return null;
return (
<span
className={cn(
"ml-3 shrink-0 rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wide",
file.deprecated
? "border-amber-500/40 bg-amber-500/10 text-amber-200"
: "border-border text-muted-foreground",
)}
>
{file.deprecated ? "deprecated" : file.isEntryFile ? "entry" : `${file.size}b`}
</span>
);
}}
/>
</div> </div>
<div className="border border-border rounded-lg p-4 space-y-3"> <div className="border border-border rounded-lg p-4 space-y-3">
@@ -1859,11 +1893,13 @@ function PromptsTab({
<h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4> <h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{selectedFileExists {selectedFileExists
? `${selectedFileDetail?.language ?? "text"} file` ? selectedFileSummary?.deprecated
? "Deprecated virtual file"
: `${selectedFileDetail?.language ?? "text"} file`
: "New file in this bundle"} : "New file in this bundle"}
</p> </p>
</div> </div>
{selectedFileExists && selectedOrEntryFile !== currentEntryFile && ( {selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
<Button <Button
type="button" type="button"
size="sm" size="sm"