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:
@@ -17,11 +17,13 @@ export interface AgentInstructionsFileSummary {
|
||||
language: string;
|
||||
markdown: boolean;
|
||||
isEntryFile: boolean;
|
||||
editable: boolean;
|
||||
deprecated: boolean;
|
||||
virtual: boolean;
|
||||
}
|
||||
|
||||
export interface AgentInstructionsFileDetail extends AgentInstructionsFileSummary {
|
||||
content: string;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface AgentInstructionsBundle {
|
||||
|
||||
@@ -109,7 +109,16 @@ describe("agent instructions bundle routes", () => {
|
||||
warnings: [],
|
||||
legacyPromptTemplateActive: 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({
|
||||
path: "AGENTS.md",
|
||||
@@ -118,6 +127,8 @@ describe("agent instructions bundle routes", () => {
|
||||
markdown: true,
|
||||
isEntryFile: true,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
content: "# Agent\n",
|
||||
});
|
||||
mockAgentInstructionsService.writeFile.mockResolvedValue({
|
||||
@@ -129,6 +140,8 @@ describe("agent instructions bundle routes", () => {
|
||||
markdown: true,
|
||||
isEntryFile: true,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
content: "# Updated Agent\n",
|
||||
},
|
||||
adapterConfig: {
|
||||
|
||||
@@ -10,6 +10,7 @@ const ENTRY_KEY = "instructionsEntryFile";
|
||||
const FILE_KEY = "instructionsFilePath";
|
||||
const PROMPT_KEY = "promptTemplate";
|
||||
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
|
||||
const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md";
|
||||
|
||||
type BundleMode = "managed" | "external";
|
||||
|
||||
@@ -26,6 +27,9 @@ type AgentInstructionsFileSummary = {
|
||||
language: string;
|
||||
markdown: boolean;
|
||||
isEntryFile: boolean;
|
||||
editable: boolean;
|
||||
deprecated: boolean;
|
||||
virtual: boolean;
|
||||
};
|
||||
|
||||
type AgentInstructionsFileDetail = AgentInstructionsFileSummary & {
|
||||
@@ -171,6 +175,9 @@ async function readFileSummary(rootPath: string, relativePath: string, entryFile
|
||||
language: inferLanguage(relativePath),
|
||||
markdown: isMarkdown(relativePath),
|
||||
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 {
|
||||
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 {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
@@ -250,7 +272,7 @@ function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructions
|
||||
warnings: state.warnings,
|
||||
legacyPromptTemplateActive: state.legacyPromptTemplateActive,
|
||||
legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive,
|
||||
files,
|
||||
files: nextFiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -317,6 +339,21 @@ export function agentInstructionsService() {
|
||||
|
||||
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
|
||||
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");
|
||||
const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath);
|
||||
const [content, stat] = await Promise.all([
|
||||
@@ -331,12 +368,14 @@ export function agentInstructionsService() {
|
||||
language: inferLanguage(normalizedPath),
|
||||
markdown: isMarkdown(normalizedPath),
|
||||
isEntryFile: normalizedPath === state.entryFile,
|
||||
content,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureManagedBundle(
|
||||
async function ensureWritableBundle(
|
||||
agent: AgentLike,
|
||||
options?: { clearLegacyPromptTemplate?: boolean },
|
||||
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
|
||||
@@ -421,7 +460,21 @@ export function agentInstructionsService() {
|
||||
file: AgentInstructionsFileDetail;
|
||||
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);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, "utf8");
|
||||
@@ -438,6 +491,9 @@ export function agentInstructionsService() {
|
||||
adapterConfig: Record<string, unknown>;
|
||||
}> {
|
||||
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");
|
||||
const normalizedPath = normalizeRelativeFilePath(relativePath);
|
||||
if (normalizedPath === state.entryFile) {
|
||||
@@ -525,7 +581,7 @@ export function agentInstructionsService() {
|
||||
writeFile,
|
||||
deleteFile,
|
||||
exportFiles,
|
||||
ensureManagedBundle,
|
||||
ensureManagedBundle: ensureWritableBundle,
|
||||
materializeManagedBundle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,49 +175,57 @@ export function PackageFileTree({
|
||||
onToggleCheck,
|
||||
renderFileExtra,
|
||||
fileRowClassName,
|
||||
showCheckboxes = true,
|
||||
depth = 0,
|
||||
}: {
|
||||
nodes: FileTreeNode[];
|
||||
selectedFile: string | null;
|
||||
expandedDirs: Set<string>;
|
||||
checkedFiles: Set<string>;
|
||||
checkedFiles?: Set<string>;
|
||||
onToggleDir: (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) */
|
||||
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
||||
/** Optional additional className for file rows */
|
||||
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
||||
showCheckboxes?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => {
|
||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||
if (node.kind === "dir") {
|
||||
const childFiles = collectAllPaths(node.children, "file");
|
||||
const allChecked = [...childFiles].every((p) => checkedFiles.has(p));
|
||||
const someChecked = [...childFiles].some((p) => checkedFiles.has(p));
|
||||
const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
|
||||
const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
|
||||
onChange={() => onToggleCheck(node.path, "dir")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
|
||||
onChange={() => onToggleCheck?.(node.path, "dir")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 py-1 text-left"
|
||||
@@ -249,12 +257,13 @@ export function PackageFileTree({
|
||||
nodes={node.children}
|
||||
selectedFile={selectedFile}
|
||||
expandedDirs={expandedDirs}
|
||||
checkedFiles={checkedFiles}
|
||||
checkedFiles={effectiveCheckedFiles}
|
||||
onToggleDir={onToggleDir}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleCheck={onToggleCheck}
|
||||
renderFileExtra={renderFileExtra}
|
||||
fileRowClassName={fileRowClassName}
|
||||
showCheckboxes={showCheckboxes}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
@@ -263,7 +272,7 @@ export function PackageFileTree({
|
||||
}
|
||||
|
||||
const FileIcon = fileIcon(node.name);
|
||||
const checked = checkedFiles.has(node.path);
|
||||
const checked = effectiveCheckedFiles.has(node.path);
|
||||
const extraClassName = fileRowClassName?.(node, checked);
|
||||
return (
|
||||
<div
|
||||
@@ -278,14 +287,16 @@ export function PackageFileTree({
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggleCheck(node.path, "file")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggleCheck?.(node.path, "file")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
|
||||
|
||||
@@ -29,6 +29,7 @@ import { EntityRow } from "../components/EntityRow";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -1495,6 +1496,7 @@ function PromptsTab({
|
||||
entryFile: string;
|
||||
} | null>(null);
|
||||
const [newFilePath, setNewFilePath] = useState("");
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [awaitingRefresh, setAwaitingRefresh] = useState(false);
|
||||
const lastFileVersionRef = useRef<string | null>(null);
|
||||
|
||||
@@ -1516,8 +1518,17 @@ function PromptsTab({
|
||||
const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
|
||||
const currentRootPath = bundleDraft?.rootPath ?? bundle?.rootPath ?? "";
|
||||
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 selectedFileExists = fileOptions.includes(selectedOrEntryFile);
|
||||
const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null;
|
||||
|
||||
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
|
||||
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
|
||||
@@ -1585,6 +1596,19 @@ function PromptsTab({
|
||||
}
|
||||
}, [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(() => {
|
||||
const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`;
|
||||
if (awaitingRefresh) {
|
||||
@@ -1708,7 +1732,7 @@ function PromptsTab({
|
||||
|
||||
{(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">
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -1827,30 +1851,40 @@ function PromptsTab({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{[...new Set([currentEntryFile, ...fileOptions])].map((filePath) => {
|
||||
const file = bundle?.files.find((entry) => entry.path === filePath);
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm",
|
||||
filePath === selectedOrEntryFile ? "border-foreground/30 bg-accent/30" : "border-border",
|
||||
)}
|
||||
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>
|
||||
);
|
||||
<PackageFileTree
|
||||
nodes={fileTree}
|
||||
selectedFile={selectedOrEntryFile}
|
||||
expandedDirs={expandedDirs}
|
||||
checkedFiles={new Set()}
|
||||
onToggleDir={(dirPath) => setExpandedDirs((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(dirPath)) next.delete(dirPath);
|
||||
else next.add(dirPath);
|
||||
return next;
|
||||
})}
|
||||
</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 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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedFileExists
|
||||
? `${selectedFileDetail?.language ?? "text"} file`
|
||||
? selectedFileSummary?.deprecated
|
||||
? "Deprecated virtual file"
|
||||
: `${selectedFileDetail?.language ?? "text"} file`
|
||||
: "New file in this bundle"}
|
||||
</p>
|
||||
</div>
|
||||
{selectedFileExists && selectedOrEntryFile !== currentEntryFile && (
|
||||
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user