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:
@@ -3,10 +3,10 @@ import {
|
||||
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
||||
Settings, FileText, ActivitySquare, X, Copy,
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
|
||||
ChevronDown, ChevronRight, BarChart3, Star
|
||||
ChevronDown, ChevronRight, BarChart3, Star, BookOpen
|
||||
} from "lucide-react";
|
||||
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 { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -52,7 +52,7 @@ interface AgentDetailViewProps {
|
||||
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 }[] = [
|
||||
{ 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: "employees", label: "Employees", icon: GitBranch },
|
||||
{ id: "soul", label: "Soul", icon: Heart },
|
||||
{ id: "instructions", label: "Instructions", icon: BookOpen },
|
||||
{ id: "memory", label: "Memory", icon: FileText },
|
||||
{ id: "reflections", label: "Reflections", icon: BarChart3 },
|
||||
{ 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" && (
|
||||
<MemoryTab
|
||||
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({
|
||||
agentId,
|
||||
projectId,
|
||||
@@ -1971,14 +2240,8 @@ function ConfigTab({
|
||||
};
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingInstructions, setIsSavingInstructions] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
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 */
|
||||
const hasChanges = (() => {
|
||||
@@ -2015,14 +2278,6 @@ function ConfigTab({
|
||||
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) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
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 (
|
||||
<div className="config-tab">
|
||||
<div className="config-section">
|
||||
@@ -2559,74 +2792,6 @@ function ConfigTab({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ vi.mock("../../api", () => ({
|
||||
fetchChainOfCommand: vi.fn(),
|
||||
fetchAgentBudgetStatus: vi.fn(),
|
||||
resetAgentBudget: vi.fn(),
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
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 mockUpdateAgent = vi.mocked(updateAgent);
|
||||
@@ -47,6 +49,9 @@ const mockFetchAgentTasks = vi.mocked(fetchAgentTasks);
|
||||
const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
|
||||
const mockFetchAgentBudgetStatus = vi.mocked(fetchAgentBudgetStatus);
|
||||
const mockResetAgentBudget = vi.mocked(resetAgentBudget);
|
||||
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
|
||||
|
||||
describe("AgentDetailView", () => {
|
||||
const createMockAgent = (overrides: Partial<{
|
||||
@@ -115,6 +120,10 @@ describe("AgentDetailView", () => {
|
||||
nextResetAt: null,
|
||||
});
|
||||
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", () => {
|
||||
@@ -407,6 +416,7 @@ describe("AgentDetailView", () => {
|
||||
expect(screen.getByText("Tasks")).toBeInTheDocument();
|
||||
expect(screen.getByText("Employees")).toBeInTheDocument();
|
||||
expect(screen.getByText("Soul")).toBeInTheDocument();
|
||||
expect(screen.getByText("Instructions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Memory")).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