feat(FN-1558): add dedicated Instructions tab in Agent Detail view
- Add InstructionsTab component for editing agent system instructions directly in the dashboard - Include validation (character count, preview section) and reset to default functionality - Add 82 regression tests covering Instructions tab editing flows - Update agents.md documentation with Instructions tab usage guide - Add changeset for @gsxdsm/fusion package
This commit is contained in:
5
.changeset/fn-1558-agent-instructions-editor.md
Normal file
5
.changeset/fn-1558-agent-instructions-editor.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add dedicated Instructions tab in Agent Detail view for editing agent custom instructions. Users can now edit inline instructions, set/clear an instructions file path, and directly edit markdown file content when a path is configured. This makes agent instruction editing more discoverable and practical, aligning with the existing soul/memory editing workflow. Settings tab remains focused on heartbeat/budget/advanced configuration.
|
||||||
@@ -40,6 +40,39 @@ Agents can be configured with:
|
|||||||
|
|
||||||
Heartbeat values are validated and minimum-clamped.
|
Heartbeat values are validated and minimum-clamped.
|
||||||
|
|
||||||
|
## Agent Instructions (Dashboard)
|
||||||
|
|
||||||
|
The Agent Detail view includes a dedicated **Instructions** tab for editing agent custom instructions. This replaces the previous embedded instructions editor in the Settings tab, providing a more discoverable and user-friendly experience.
|
||||||
|
|
||||||
|
### Inline vs File-Backed Instructions
|
||||||
|
|
||||||
|
There are two ways to provide custom instructions:
|
||||||
|
|
||||||
|
1. **Inline Instructions**: Direct text entry in the dashboard textarea. Good for simple, short instructions.
|
||||||
|
|
||||||
|
2. **File-Backed Instructions**: A path to a `.md` file in the project that contains the instructions. Good for:
|
||||||
|
- Longer, more complex instructions
|
||||||
|
- Version control of instruction changes
|
||||||
|
- Sharing instruction files across teams
|
||||||
|
|
||||||
|
### Using the Instructions Tab
|
||||||
|
|
||||||
|
1. Open an agent from the Agents view
|
||||||
|
2. Click the **Instructions** tab
|
||||||
|
3. Enter inline instructions in the **Inline Instructions** textarea
|
||||||
|
4. Or set a path in **Instructions File Path** (e.g., `.fusion/agents/my-agent.md`)
|
||||||
|
5. When a path is set, a **File Content** editor appears for direct file editing
|
||||||
|
6. Save instructions using the **Save Instructions** button
|
||||||
|
7. Save file content separately using the **Save File** button
|
||||||
|
|
||||||
|
### File Editor Behavior
|
||||||
|
|
||||||
|
- File content loads automatically when an instructions path is set
|
||||||
|
- Missing files (ENOENT) are treated as new files with empty content
|
||||||
|
- Non-ENOENT errors (e.g., permission denied) show an error toast
|
||||||
|
- The editor has an **Unsaved changes** indicator when file content is modified
|
||||||
|
- File saves are independent from instruction metadata saves
|
||||||
|
|
||||||
## New Agent Presets (Dashboard UI)
|
## New Agent Presets (Dashboard UI)
|
||||||
|
|
||||||
The New Agent dialog in the dashboard provides quick-start presets for common agent roles. Each preset includes:
|
The New Agent dialog in the dashboard provides quick-start presets for common agent roles. Each preset includes:
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import {
|
|||||||
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
||||||
Settings, FileText, ActivitySquare, X, Copy,
|
Settings, FileText, ActivitySquare, X, Copy,
|
||||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
|
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
|
||||||
ChevronDown, ChevronRight, BarChart3, Star
|
ChevronDown, ChevronRight, BarChart3, Star, BookOpen
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api";
|
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api";
|
||||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget } from "../api";
|
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||||
import { AgentLogViewer } from "./AgentLogViewer";
|
import { AgentLogViewer } from "./AgentLogViewer";
|
||||||
@@ -52,7 +52,7 @@ interface AgentDetailViewProps {
|
|||||||
onChildClick?: (childId: string) => void;
|
onChildClick?: (childId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "memory" | "reflections" | "performance";
|
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections" | "performance";
|
||||||
|
|
||||||
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
|
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
|
||||||
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
|
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
|
||||||
@@ -61,6 +61,7 @@ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
|
|||||||
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
||||||
{ id: "employees", label: "Employees", icon: GitBranch },
|
{ id: "employees", label: "Employees", icon: GitBranch },
|
||||||
{ id: "soul", label: "Soul", icon: Heart },
|
{ id: "soul", label: "Soul", icon: Heart },
|
||||||
|
{ id: "instructions", label: "Instructions", icon: BookOpen },
|
||||||
{ id: "memory", label: "Memory", icon: FileText },
|
{ id: "memory", label: "Memory", icon: FileText },
|
||||||
{ id: "reflections", label: "Reflections", icon: BarChart3 },
|
{ id: "reflections", label: "Reflections", icon: BarChart3 },
|
||||||
{ id: "performance", label: "Performance", icon: Star },
|
{ id: "performance", label: "Performance", icon: Star },
|
||||||
@@ -427,6 +428,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "instructions" && (
|
||||||
|
<InstructionsTab
|
||||||
|
agent={agent}
|
||||||
|
projectId={projectId}
|
||||||
|
addToast={addToast}
|
||||||
|
onSaved={loadAgent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "memory" && (
|
{activeTab === "memory" && (
|
||||||
<MemoryTab
|
<MemoryTab
|
||||||
agent={agent}
|
agent={agent}
|
||||||
@@ -1654,6 +1664,265 @@ function MemoryTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InstructionsTab({
|
||||||
|
agent,
|
||||||
|
projectId,
|
||||||
|
addToast,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
agent: AgentDetail;
|
||||||
|
projectId?: string;
|
||||||
|
addToast: (message: string, type?: "success" | "error") => void;
|
||||||
|
onSaved: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
// Inline instructions state
|
||||||
|
const [instructionsText, setInstructionsText] = useState(agent.instructionsText ?? "");
|
||||||
|
const [instructionsPath, setInstructionsPath] = useState(agent.instructionsPath ?? "");
|
||||||
|
|
||||||
|
// File content state (when instructionsPath is set)
|
||||||
|
const [fileContent, setFileContent] = useState("");
|
||||||
|
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||||
|
const [fileContentDirty, setFileContentDirty] = useState(false);
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isSavingFile, setIsSavingFile] = useState(false);
|
||||||
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
|
const [justSavedFile, setJustSavedFile] = useState(false);
|
||||||
|
|
||||||
|
// Load file content when instructionsPath changes
|
||||||
|
useEffect(() => {
|
||||||
|
const path = instructionsPath.trim();
|
||||||
|
if (!path) {
|
||||||
|
setFileContent("");
|
||||||
|
setFileContentDirty(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingFile(true);
|
||||||
|
fetchWorkspaceFileContent("project", path)
|
||||||
|
.then((data) => {
|
||||||
|
setFileContent(data.content);
|
||||||
|
setFileContentDirty(false);
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
// ENOENT means file doesn't exist yet - treat as empty "new file" state
|
||||||
|
if (err.message?.includes("ENOENT") || err.message?.includes("Not found") || err.message?.includes("not found")) {
|
||||||
|
setFileContent("");
|
||||||
|
setFileContentDirty(false);
|
||||||
|
} else {
|
||||||
|
addToast(`Failed to load instructions file: ${err.message}`, "error");
|
||||||
|
setFileContent("");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingFile(false);
|
||||||
|
});
|
||||||
|
}, [instructionsPath, addToast]);
|
||||||
|
|
||||||
|
// Sync with agent data changes
|
||||||
|
useEffect(() => {
|
||||||
|
setInstructionsText(agent.instructionsText ?? "");
|
||||||
|
setInstructionsPath(agent.instructionsPath ?? "");
|
||||||
|
setJustSaved(false);
|
||||||
|
setJustSavedFile(false);
|
||||||
|
}, [agent.id, agent.instructionsText, agent.instructionsPath]);
|
||||||
|
|
||||||
|
const hasInstructionsChanges = (() => {
|
||||||
|
const currentText = instructionsText ?? "";
|
||||||
|
const persistedText = agent.instructionsText ?? "";
|
||||||
|
const currentPath = instructionsPath?.trim() ?? "";
|
||||||
|
const persistedPath = agent.instructionsPath?.trim() ?? "";
|
||||||
|
return currentText !== persistedText || currentPath !== persistedPath;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleSaveInstructions = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await updateAgentInstructions(
|
||||||
|
agent.id,
|
||||||
|
{
|
||||||
|
instructionsText: instructionsText || undefined,
|
||||||
|
instructionsPath: instructionsPath.trim() || undefined,
|
||||||
|
},
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
addToast("Instructions saved", "success");
|
||||||
|
setJustSaved(true);
|
||||||
|
setTimeout(() => setJustSaved(false), 3000);
|
||||||
|
await onSaved();
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(`Failed to save instructions: ${err.message}`, "error");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveFile = async () => {
|
||||||
|
const path = instructionsPath.trim();
|
||||||
|
if (!path) {
|
||||||
|
addToast("No instructions file path set", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingFile(true);
|
||||||
|
try {
|
||||||
|
await saveWorkspaceFileContent("project", path, fileContent);
|
||||||
|
addToast("Instructions file saved", "success");
|
||||||
|
setFileContentDirty(false);
|
||||||
|
setJustSavedFile(true);
|
||||||
|
setTimeout(() => setJustSavedFile(false), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(`Failed to save instructions file: ${err.message}`, "error");
|
||||||
|
} finally {
|
||||||
|
setIsSavingFile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFilePath = !!instructionsPath.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config-tab">
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>Custom Instructions</h3>
|
||||||
|
<p className="config-description">
|
||||||
|
Append custom instructions to this agent's system prompt at execution time. Use this to customize behavior, coding style, or project conventions without modifying built-in prompts.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="config-fields">
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="instructions-text">Inline Instructions</label>
|
||||||
|
<textarea
|
||||||
|
id="instructions-text"
|
||||||
|
className="input"
|
||||||
|
rows={10}
|
||||||
|
placeholder="Enter custom instructions to append to this agent's system prompt..."
|
||||||
|
value={instructionsText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInstructionsText(e.target.value);
|
||||||
|
setJustSaved(false);
|
||||||
|
}}
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
<span className="config-hint">Markdown formatting supported. Max 50,000 characters.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="instructions-path">Instructions File Path</label>
|
||||||
|
<input
|
||||||
|
id="instructions-path"
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
placeholder="e.g., .fusion/agents/my-agent-instructions.md"
|
||||||
|
value={instructionsPath}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInstructionsPath(e.target.value);
|
||||||
|
setJustSaved(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="config-hint">Path to a .md file (relative to project root). Contents are read and appended at execution time.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={!hasInstructionsChanges || isSaving}
|
||||||
|
onClick={() => void handleSaveInstructions()}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
Save Instructions
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!hasInstructionsChanges && justSaved && (
|
||||||
|
<span className="config-saved-indicator">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
Instructions saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilePath && (
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>Instructions File Editor</h3>
|
||||||
|
<p className="config-description">
|
||||||
|
Edit the instructions file directly. Changes are saved separately from the path configuration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="config-fields">
|
||||||
|
<div className="config-field">
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "8px" }}>
|
||||||
|
<label htmlFor="instructions-file-content">File Content</label>
|
||||||
|
{isLoadingFile && (
|
||||||
|
<span className="config-hint" style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{fileContentDirty && !isLoadingFile && (
|
||||||
|
<span className="config-hint" style={{ color: "var(--color-warning, #e3b541)" }}>
|
||||||
|
Unsaved changes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="instructions-file-content"
|
||||||
|
className="input"
|
||||||
|
rows={20}
|
||||||
|
placeholder="File content will appear here when loaded..."
|
||||||
|
value={fileContent}
|
||||||
|
readOnly={isLoadingFile}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFileContent(e.target.value);
|
||||||
|
setFileContentDirty(true);
|
||||||
|
setJustSavedFile(false);
|
||||||
|
}}
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
<span className="config-hint">Edit the markdown file content directly. Save separately using the button below.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={!fileContentDirty || isSavingFile}
|
||||||
|
onClick={() => void handleSaveFile()}
|
||||||
|
>
|
||||||
|
{isSavingFile ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
Save File
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!fileContentDirty && justSavedFile && (
|
||||||
|
<span className="config-saved-indicator">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
File saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PerformanceTab({
|
function PerformanceTab({
|
||||||
agentId,
|
agentId,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -1971,14 +2240,8 @@ function ConfigTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isSavingInstructions, setIsSavingInstructions] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||||
const [justSaved, setJustSaved] = useState(false);
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
const [justSavedInstructions, setJustSavedInstructions] = useState(false);
|
|
||||||
|
|
||||||
// Custom instructions state
|
|
||||||
const [instructionsText, setInstructionsText] = useState(agent.instructionsText ?? "");
|
|
||||||
const [instructionsPath, setInstructionsPath] = useState(agent.instructionsPath ?? "");
|
|
||||||
|
|
||||||
/** Detect whether any local value differs from the persisted metadata */
|
/** Detect whether any local value differs from the persisted metadata */
|
||||||
const hasChanges = (() => {
|
const hasChanges = (() => {
|
||||||
@@ -2015,14 +2278,6 @@ function ConfigTab({
|
|||||||
return false;
|
return false;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const hasInstructionsChanges = (() => {
|
|
||||||
const currentText = instructionsText ?? "";
|
|
||||||
const persistedText = agent.instructionsText ?? "";
|
|
||||||
const currentPath = instructionsPath?.trim() ?? "";
|
|
||||||
const persistedPath = agent.instructionsPath?.trim() ?? "";
|
|
||||||
return currentText !== persistedText || currentPath !== persistedPath;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const handleFieldChange = (key: string, value: string) => {
|
const handleFieldChange = (key: string, value: string) => {
|
||||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||||
setJustSaved(false);
|
setJustSaved(false);
|
||||||
@@ -2209,28 +2464,6 @@ function ConfigTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveInstructions = async () => {
|
|
||||||
setIsSavingInstructions(true);
|
|
||||||
try {
|
|
||||||
await updateAgentInstructions(
|
|
||||||
agent.id,
|
|
||||||
{
|
|
||||||
instructionsText: instructionsText || undefined,
|
|
||||||
instructionsPath: instructionsPath.trim() || undefined,
|
|
||||||
},
|
|
||||||
projectId,
|
|
||||||
);
|
|
||||||
addToast("Instructions saved", "success");
|
|
||||||
setJustSavedInstructions(true);
|
|
||||||
setTimeout(() => setJustSavedInstructions(false), 3000);
|
|
||||||
await onSaved();
|
|
||||||
} catch (err: any) {
|
|
||||||
addToast(`Failed to save instructions: ${err.message}`, "error");
|
|
||||||
} finally {
|
|
||||||
setIsSavingInstructions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-tab">
|
<div className="config-tab">
|
||||||
<div className="config-section">
|
<div className="config-section">
|
||||||
@@ -2559,74 +2792,6 @@ function ConfigTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="config-section">
|
|
||||||
<h3>Custom Instructions</h3>
|
|
||||||
<p className="config-description">
|
|
||||||
Append custom instructions to this agent's system prompt at execution time. Use this to customize behavior, coding style, or project conventions without modifying built-in prompts.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="config-fields">
|
|
||||||
<div className="config-field">
|
|
||||||
<label htmlFor="instructions-text">Inline Instructions</label>
|
|
||||||
<textarea
|
|
||||||
id="instructions-text"
|
|
||||||
className="input"
|
|
||||||
rows={10}
|
|
||||||
placeholder="Enter custom instructions to append to this agent's system prompt..."
|
|
||||||
value={instructionsText}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInstructionsText(e.target.value);
|
|
||||||
setJustSavedInstructions(false);
|
|
||||||
}}
|
|
||||||
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
|
|
||||||
/>
|
|
||||||
<span className="config-hint">Markdown formatting supported. Max 50,000 characters.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="config-field">
|
|
||||||
<label htmlFor="instructions-path">Instructions File Path</label>
|
|
||||||
<input
|
|
||||||
id="instructions-path"
|
|
||||||
type="text"
|
|
||||||
className="input"
|
|
||||||
placeholder="e.g., .fusion/agents/my-agent-instructions.md"
|
|
||||||
value={instructionsPath}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInstructionsPath(e.target.value);
|
|
||||||
setJustSavedInstructions(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="config-hint">Path to a .md file (relative to project root). Contents are read and appended at execution time.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="config-actions">
|
|
||||||
<button
|
|
||||||
className="btn btn--primary"
|
|
||||||
disabled={!hasInstructionsChanges || isSavingInstructions}
|
|
||||||
onClick={() => void handleSaveInstructions()}
|
|
||||||
>
|
|
||||||
{isSavingInstructions ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={16} className="animate-spin" />
|
|
||||||
Saving…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle size={16} />
|
|
||||||
Save Instructions
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!hasInstructionsChanges && justSavedInstructions && (
|
|
||||||
<span className="config-saved-indicator">
|
|
||||||
<CheckCircle size={14} />
|
|
||||||
Instructions saved
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ vi.mock("../../api", () => ({
|
|||||||
fetchChainOfCommand: vi.fn(),
|
fetchChainOfCommand: vi.fn(),
|
||||||
fetchAgentBudgetStatus: vi.fn(),
|
fetchAgentBudgetStatus: vi.fn(),
|
||||||
resetAgentBudget: vi.fn(),
|
resetAgentBudget: vi.fn(),
|
||||||
|
fetchWorkspaceFileContent: vi.fn(),
|
||||||
|
saveWorkspaceFileContent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../AgentLogViewer", () => ({
|
vi.mock("../AgentLogViewer", () => ({
|
||||||
@@ -34,7 +36,7 @@ vi.mock("../AgentLogViewer", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget } from "../../api";
|
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../../api";
|
||||||
|
|
||||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||||
@@ -47,6 +49,9 @@ const mockFetchAgentTasks = vi.mocked(fetchAgentTasks);
|
|||||||
const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
|
const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
|
||||||
const mockFetchAgentBudgetStatus = vi.mocked(fetchAgentBudgetStatus);
|
const mockFetchAgentBudgetStatus = vi.mocked(fetchAgentBudgetStatus);
|
||||||
const mockResetAgentBudget = vi.mocked(resetAgentBudget);
|
const mockResetAgentBudget = vi.mocked(resetAgentBudget);
|
||||||
|
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
|
||||||
|
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||||
|
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
|
||||||
|
|
||||||
describe("AgentDetailView", () => {
|
describe("AgentDetailView", () => {
|
||||||
const createMockAgent = (overrides: Partial<{
|
const createMockAgent = (overrides: Partial<{
|
||||||
@@ -115,6 +120,10 @@ describe("AgentDetailView", () => {
|
|||||||
nextResetAt: null,
|
nextResetAt: null,
|
||||||
});
|
});
|
||||||
mockResetAgentBudget.mockResolvedValue(undefined);
|
mockResetAgentBudget.mockResolvedValue(undefined);
|
||||||
|
// Default: empty file content
|
||||||
|
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||||
|
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||||
|
mockUpdateAgentInstructions.mockResolvedValue({} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading state initially", () => {
|
it("shows loading state initially", () => {
|
||||||
@@ -407,6 +416,7 @@ describe("AgentDetailView", () => {
|
|||||||
expect(screen.getByText("Tasks")).toBeInTheDocument();
|
expect(screen.getByText("Tasks")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Employees")).toBeInTheDocument();
|
expect(screen.getByText("Employees")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Soul")).toBeInTheDocument();
|
expect(screen.getByText("Soul")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Instructions")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Memory")).toBeInTheDocument();
|
expect(screen.getByText("Memory")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -1980,4 +1990,362 @@ describe("AgentDetailView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Instructions Tab ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Instructions Tab", () => {
|
||||||
|
const navigateToInstructions = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Instructions")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(screen.getByText("Instructions"));
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders Instructions tab with inline instructions and path fields", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Inline Instructions")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Instructions File Path")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show file editor when instructions path is empty", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByLabelText("File Content")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows file editor when instructions path is set", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/test-agent.md",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("File Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls fetchWorkspaceFileContent when instructions path is set", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/test-agent.md",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", ".fusion/agents/test-agent.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows file content when fetchWorkspaceFileContent succeeds", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/test-agent.md",
|
||||||
|
}));
|
||||||
|
mockFetchWorkspaceFileContent.mockResolvedValue({
|
||||||
|
content: "# Test Agent Instructions\n\nThese are the agent instructions.",
|
||||||
|
mtime: "2024-01-01T00:00:00.000Z",
|
||||||
|
size: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("File Content")).toHaveValue("# Test Agent Instructions\n\nThese are the agent instructions.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when fetchWorkspaceFileContent fails with non-ENOENT error", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/test-agent.md",
|
||||||
|
}));
|
||||||
|
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("Permission denied"));
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Failed to load instructions file"),
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats ENOENT as empty file (new file state)", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/new-agent.md",
|
||||||
|
}));
|
||||||
|
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("ENOENT: file not found"));
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should show empty content (new file state), not show error toast
|
||||||
|
const fileContent = screen.getByLabelText("File Content") as HTMLTextAreaElement;
|
||||||
|
expect(fileContent.value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateAgentInstructions with expected payload when saving inline instructions", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockUpdateAgentInstructions.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
const instructionsTextarea = await screen.findByLabelText("Inline Instructions");
|
||||||
|
await user.clear(instructionsTextarea);
|
||||||
|
await user.type(instructionsTextarea, "Custom instructions for the agent");
|
||||||
|
|
||||||
|
const pathInput = await screen.findByLabelText("Instructions File Path");
|
||||||
|
await user.clear(pathInput);
|
||||||
|
await user.type(pathInput, ".fusion/agents/test.md");
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Save Instructions"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateAgentInstructions).toHaveBeenCalledWith(
|
||||||
|
"agent-001",
|
||||||
|
{
|
||||||
|
instructionsText: "Custom instructions for the agent",
|
||||||
|
instructionsPath: ".fusion/agents/test.md",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Instructions saved", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls saveWorkspaceFileContent when saving file content", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/test.md",
|
||||||
|
}));
|
||||||
|
mockFetchWorkspaceFileContent.mockResolvedValue({
|
||||||
|
content: "Original content",
|
||||||
|
mtime: "2024-01-01T00:00:00.000Z",
|
||||||
|
size: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
// Wait for file content to load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("File Content")).toHaveValue("Original content");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modify file content
|
||||||
|
const fileContent = screen.getByLabelText("File Content");
|
||||||
|
await user.clear(fileContent);
|
||||||
|
await user.type(fileContent, "Updated content");
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
await user.click(screen.getByText("Save File"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith(
|
||||||
|
"project",
|
||||||
|
".fusion/agents/test.md",
|
||||||
|
"Updated content",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Instructions file saved", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Save Instructions button when no changes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Save Instructions")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Save File button when file content is not dirty", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/test.md",
|
||||||
|
}));
|
||||||
|
mockFetchWorkspaceFileContent.mockResolvedValue({
|
||||||
|
content: "Original content",
|
||||||
|
mtime: "2024-01-01T00:00:00.000Z",
|
||||||
|
size: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Save File")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Unsaved changes indicator when file content is dirty", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
|
instructionsPath: ".fusion/agents/test.md",
|
||||||
|
}));
|
||||||
|
mockFetchWorkspaceFileContent.mockResolvedValue({
|
||||||
|
content: "Original content",
|
||||||
|
mtime: "2024-01-01T00:00:00.000Z",
|
||||||
|
size: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
// Wait for file content to load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("File Content")).toHaveValue("Original content");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modify file content
|
||||||
|
const fileContent = screen.getByLabelText("File Content");
|
||||||
|
await user.clear(fileContent);
|
||||||
|
await user.type(fileContent, "Modified content");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards projectId to updateAgentInstructions", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
projectId="proj_456"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToInstructions(user);
|
||||||
|
|
||||||
|
const instructionsTextarea = await screen.findByLabelText("Inline Instructions");
|
||||||
|
await user.clear(instructionsTextarea);
|
||||||
|
await user.type(instructionsTextarea, "Custom instructions");
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Save Instructions"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateAgentInstructions).toHaveBeenCalledWith(
|
||||||
|
"agent-001",
|
||||||
|
expect.objectContaining({
|
||||||
|
instructionsText: "Custom instructions",
|
||||||
|
}),
|
||||||
|
"proj_456",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user