feat(FN-1171): add agent soul memory and employees workflows

- Add soul and memory fields to agent types and AgentStore with persistence/update test coverage
- Add dashboard routes and API helpers to fetch and update agent soul/memory data
- Extend AgentDetailView with Soul, Memory, and Employees tabs and rename children labels to employees
- Normalize employee route params for type safety and add focused route/component tests for the new flows
This commit is contained in:
gsxdsm
2026-04-08 13:00:12 -07:00
parent d3c5f35f3d
commit 67ce1ca2ca
10 changed files with 770 additions and 28 deletions

View File

@@ -81,6 +81,22 @@ describe("AgentStore", () => {
expect(agent.metadata).toEqual({ version: 2, tags: ["test"] }); expect(agent.metadata).toEqual({ version: 2, tags: ["test"] });
}); });
it("persists soul and memory fields on create", async () => {
const agent = await store.createAgent({
name: "With Soul",
role: "executor",
soul: "Calm and precise.",
memory: "Prefers concise code examples.",
});
expect(agent.soul).toBe("Calm and precise.");
expect(agent.memory).toBe("Prefers concise code examples.");
const persisted = await store.getAgent(agent.id);
expect(persisted?.soul).toBe("Calm and precise.");
expect(persisted?.memory).toBe("Prefers concise code examples.");
});
it("throws when name is empty", async () => { it("throws when name is empty", async () => {
await expect( await expect(
store.createAgent({ name: "", role: "executor" }) store.createAgent({ name: "", role: "executor" })
@@ -540,6 +556,41 @@ describe("AgentStore", () => {
expect(updated.metadata).toEqual({ preserved: true }); // preserved expect(updated.metadata).toEqual({ preserved: true }); // preserved
}); });
it("updates soul and memory fields", async () => {
const created = await store.createAgent({
name: "Knowledge Agent",
role: "executor",
});
const updated = await store.updateAgent(created.id, {
soul: "Collaborative, practical mentor",
memory: "Avoids broad rewrites; prefers incremental changes.",
});
expect(updated.soul).toBe("Collaborative, practical mentor");
expect(updated.memory).toBe("Avoids broad rewrites; prefers incremental changes.");
const persisted = await store.getAgent(created.id);
expect(persisted?.soul).toBe("Collaborative, practical mentor");
expect(persisted?.memory).toBe("Avoids broad rewrites; prefers incremental changes.");
});
it("does not clear soul when updates.soul is undefined", async () => {
const created = await store.createAgent({
name: "Stable Soul",
role: "executor",
soul: "Patient reviewer",
});
const updated = await store.updateAgent(created.id, {
soul: undefined,
memory: "Remembers coding preferences",
});
expect(updated.soul).toBe("Patient reviewer");
expect(updated.memory).toBe("Remembers coding preferences");
});
it("allows clearing optional fields via explicit undefined", async () => { it("allows clearing optional fields via explicit undefined", async () => {
const created = await store.createAgent({ const created = await store.createAgent({
name: "Clearable", name: "Clearable",
@@ -619,7 +670,7 @@ describe("AgentStore", () => {
expect(revisions[0].after.name).toBe("Renamed"); expect(revisions[0].after.name).toBe("Renamed");
}); });
it("records revisions for runtimeConfig, permissions, instructionsPath, and instructionsText changes", async () => { it("records revisions for runtimeConfig, permissions, instructions, soul, and memory changes", async () => {
const created = await store.createAgent({ const created = await store.createAgent({
name: "Configurable", name: "Configurable",
role: "executor", role: "executor",
@@ -631,6 +682,8 @@ describe("AgentStore", () => {
await store.updateAgent(created.id, { permissions: { canReview: true, canExecute: true } }); await store.updateAgent(created.id, { permissions: { canReview: true, canExecute: true } });
await store.updateAgent(created.id, { instructionsPath: "docs/agent.md" }); await store.updateAgent(created.id, { instructionsPath: "docs/agent.md" });
await store.updateAgent(created.id, { instructionsText: "Follow safety checks." }); await store.updateAgent(created.id, { instructionsText: "Follow safety checks." });
await store.updateAgent(created.id, { soul: "Thoughtful collaborator" });
await store.updateAgent(created.id, { memory: "Knows the repository architecture" });
const revisions = await store.getConfigRevisions(created.id); const revisions = await store.getConfigRevisions(created.id);
const changedFields = revisions.flatMap((revision) => revision.diffs.map((diff) => diff.field)); const changedFields = revisions.flatMap((revision) => revision.diffs.map((diff) => diff.field));
@@ -639,6 +692,8 @@ describe("AgentStore", () => {
expect(changedFields).toContain("permissions"); expect(changedFields).toContain("permissions");
expect(changedFields).toContain("instructionsPath"); expect(changedFields).toContain("instructionsPath");
expect(changedFields).toContain("instructionsText"); expect(changedFields).toContain("instructionsText");
expect(changedFields).toContain("soul");
expect(changedFields).toContain("memory");
}); });
it("does not create a revision when only non-config fields change", async () => { it("does not create a revision when only non-config fields change", async () => {

View File

@@ -102,6 +102,8 @@ interface AgentData {
lastError?: string; lastError?: string;
instructionsPath?: string; instructionsPath?: string;
instructionsText?: string; instructionsText?: string;
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig; bundleConfig?: InstructionsBundleConfig;
} }
interface AgentLock { interface AgentLock {
@@ -173,6 +175,8 @@ export class AgentStore extends EventEmitter {
...(input.permissions && { permissions: input.permissions }), ...(input.permissions && { permissions: input.permissions }),
...(input.instructionsPath && { instructionsPath: input.instructionsPath }), ...(input.instructionsPath && { instructionsPath: input.instructionsPath }),
...(input.instructionsText && { instructionsText: input.instructionsText }), ...(input.instructionsText && { instructionsText: input.instructionsText }),
...(input.soul && { soul: input.soul }),
...(input.memory && { memory: input.memory }),
...(input.bundleConfig && { bundleConfig: input.bundleConfig }), ...(input.bundleConfig && { bundleConfig: input.bundleConfig }),
}; };
@@ -628,6 +632,8 @@ export class AgentStore extends EventEmitter {
...("totalOutputTokens" in updates && { totalOutputTokens: updates.totalOutputTokens }), ...("totalOutputTokens" in updates && { totalOutputTokens: updates.totalOutputTokens }),
...("instructionsPath" in updates && { instructionsPath: updates.instructionsPath }), ...("instructionsPath" in updates && { instructionsPath: updates.instructionsPath }),
...("instructionsText" in updates && { instructionsText: updates.instructionsText }), ...("instructionsText" in updates && { instructionsText: updates.instructionsText }),
...(updates.soul !== undefined && { soul: updates.soul }),
...(updates.memory !== undefined && { memory: updates.memory }),
...("bundleConfig" in updates && { bundleConfig: updates.bundleConfig }), ...("bundleConfig" in updates && { bundleConfig: updates.bundleConfig }),
}; };
@@ -1544,6 +1550,8 @@ export class AgentStore extends EventEmitter {
| "permissions" | "permissions"
| "instructionsPath" | "instructionsPath"
| "instructionsText" | "instructionsText"
| "soul"
| "memory"
| "bundleConfig" | "bundleConfig"
| "metadata" | "metadata"
> { > {
@@ -1557,6 +1565,8 @@ export class AgentStore extends EventEmitter {
permissions: snapshot.permissions ? { ...snapshot.permissions } : undefined, permissions: snapshot.permissions ? { ...snapshot.permissions } : undefined,
instructionsPath: snapshot.instructionsPath, instructionsPath: snapshot.instructionsPath,
instructionsText: snapshot.instructionsText, instructionsText: snapshot.instructionsText,
soul: snapshot.soul,
memory: snapshot.memory,
bundleConfig: snapshot.bundleConfig bundleConfig: snapshot.bundleConfig
? { ? {
...snapshot.bundleConfig, ...snapshot.bundleConfig,
@@ -1756,6 +1766,8 @@ export class AgentStore extends EventEmitter {
lastError: data.lastError, lastError: data.lastError,
instructionsPath: data.instructionsPath, instructionsPath: data.instructionsPath,
instructionsText: data.instructionsText, instructionsText: data.instructionsText,
soul: data.soul,
memory: data.memory,
bundleConfig: data.bundleConfig, bundleConfig: data.bundleConfig,
}; };
} }
@@ -1783,6 +1795,8 @@ export class AgentStore extends EventEmitter {
lastError: agent.lastError, lastError: agent.lastError,
instructionsPath: agent.instructionsPath, instructionsPath: agent.instructionsPath,
instructionsText: agent.instructionsText, instructionsText: agent.instructionsText,
soul: agent.soul,
memory: agent.memory,
bundleConfig: agent.bundleConfig, bundleConfig: agent.bundleConfig,
}; };

View File

@@ -1818,6 +1818,10 @@ export interface Agent {
instructionsPath?: string; instructionsPath?: string;
/** Inline custom instructions appended to the agent's system prompt at execution time. Max 50,000 chars. */ /** Inline custom instructions appended to the agent's system prompt at execution time. Max 50,000 chars. */
instructionsText?: string; instructionsText?: string;
/** Agent personality/identity description — defines the agent's character, tone, and behavioral traits. Max 10,000 chars. */
soul?: string;
/** Per-agent accumulated knowledge — stores learnings, preferences, and context the agent has gathered. Max 50,000 chars. */
memory?: string;
/** Structured instruction bundle configuration for managed/external markdown files. */ /** Structured instruction bundle configuration for managed/external markdown files. */
bundleConfig?: InstructionsBundleConfig; bundleConfig?: InstructionsBundleConfig;
} }
@@ -1919,6 +1923,8 @@ export interface AgentCreateInput {
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
instructionsPath?: string; instructionsPath?: string;
instructionsText?: string; instructionsText?: string;
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig; bundleConfig?: InstructionsBundleConfig;
} }
@@ -1938,6 +1944,8 @@ export interface AgentUpdateInput {
totalOutputTokens?: number; totalOutputTokens?: number;
instructionsPath?: string; instructionsPath?: string;
instructionsText?: string; instructionsText?: string;
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig; bundleConfig?: InstructionsBundleConfig;
} }
@@ -2028,6 +2036,8 @@ export interface AgentConfigSnapshot {
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
instructionsPath?: string; instructionsPath?: string;
instructionsText?: string; instructionsText?: string;
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig; bundleConfig?: InstructionsBundleConfig;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
} }
@@ -2073,6 +2083,8 @@ export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
permissions: agent.permissions ? { ...agent.permissions } : undefined, permissions: agent.permissions ? { ...agent.permissions } : undefined,
instructionsPath: agent.instructionsPath, instructionsPath: agent.instructionsPath,
instructionsText: agent.instructionsText, instructionsText: agent.instructionsText,
soul: agent.soul,
memory: agent.memory,
bundleConfig: agent.bundleConfig bundleConfig: agent.bundleConfig
? { ? {
...agent.bundleConfig, ...agent.bundleConfig,
@@ -2098,6 +2110,8 @@ export function diffConfigSnapshots(
"permissions", "permissions",
"instructionsPath", "instructionsPath",
"instructionsText", "instructionsText",
"soul",
"memory",
"bundleConfig", "bundleConfig",
"metadata", "metadata",
]; ];

View File

@@ -1984,6 +1984,32 @@ export function updateAgentInstructions(
}); });
} }
/** Fetch agent soul/personality text */
export function fetchAgentSoul(agentId: string, projectId?: string): Promise<{ soul: string | null }> {
return api<{ soul: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId));
}
/** Update agent soul/personality text */
export function updateAgentSoul(agentId: string, soul: string, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId), {
method: "PATCH",
body: JSON.stringify({ soul }),
});
}
/** Fetch per-agent memory text */
export function fetchAgentMemory(agentId: string, projectId?: string): Promise<{ memory: string | null }> {
return api<{ memory: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId));
}
/** Update per-agent memory text */
export function updateAgentMemory(agentId: string, memory: string, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId), {
method: "PATCH",
body: JSON.stringify({ memory }),
});
}
/** Update an agent's state */ /** Update an agent's state */
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> { export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), { return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {
@@ -2073,7 +2099,7 @@ export function resolveAgent(shortname: string, projectId?: string): Promise<{ a
return api<{ agent: Agent }>(withProjectId(`/agents/resolve/${encodeURIComponent(shortname)}`, projectId)); return api<{ agent: Agent }>(withProjectId(`/agents/resolve/${encodeURIComponent(shortname)}`, projectId));
} }
/** Fetch child agents that report to a given parent agent */ /** Fetch employees (agents that report to a given parent agent) */
export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> { export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> {
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => { return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => {
// Return empty array for 404 (agent may have been deleted) // Return empty array for 404 (agent may have been deleted)
@@ -2082,6 +2108,9 @@ export function fetchAgentChildren(agentId: string, projectId?: string): Promise
}); });
} }
/** Alias for fetchAgentChildren with employee-focused naming */
export const fetchAgentEmployees = fetchAgentChildren;
/** Assign or unassign a task to an explicit agent */ /** Assign or unassign a task to an explicit agent */
export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise<Task> { export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), { return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), {

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight ChevronDown, ChevronRight
} from "lucide-react"; } from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api"; import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, fetchAgentTasks, fetchChainOfCommand } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } 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";
@@ -50,14 +50,16 @@ interface AgentDetailViewProps {
onChildClick?: (childId: string) => void; onChildClick?: (childId: string) => void;
} }
type TabId = "dashboard" | "logs" | "config" | "runs" | "children" | "tasks"; type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "memory";
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 },
{ id: "logs", label: "Logs", icon: FileText }, { id: "logs", label: "Logs", icon: FileText },
{ id: "runs", label: "Runs", icon: Activity }, { id: "runs", label: "Runs", icon: Activity },
{ id: "tasks", label: "Tasks", icon: ListChecks }, { id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "children", label: "Children", icon: GitBranch }, { id: "employees", label: "Employees", icon: GitBranch },
{ id: "soul", label: "Soul", icon: Heart },
{ id: "memory", label: "Memory", icon: FileText },
{ id: "config", label: "Settings", icon: Settings }, { id: "config", label: "Settings", icon: Settings },
]; ];
@@ -407,8 +409,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
/> />
)} )}
{activeTab === "config" && ( {activeTab === "employees" && (
<ConfigTab <EmployeesTab
agentId={agent.id}
projectId={projectId}
onChildClick={onChildClick}
/>
)}
{activeTab === "soul" && (
<SoulTab
agent={agent} agent={agent}
projectId={projectId} projectId={projectId}
addToast={addToast} addToast={addToast}
@@ -416,11 +426,21 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
/> />
)} )}
{activeTab === "children" && ( {activeTab === "memory" && (
<ChildrenTab <MemoryTab
agentId={agent.id} agent={agent}
projectId={projectId} projectId={projectId}
onChildClick={onChildClick} addToast={addToast}
onSaved={loadAgent}
/>
)}
{activeTab === "config" && (
<ConfigTab
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={loadAgent}
/> />
)} )}
</div> </div>
@@ -1330,6 +1350,211 @@ function validateAdvancedSettings(
return errors; return errors;
} }
function SoulTab({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [soul, setSoul] = useState(agent.soul ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
useEffect(() => {
setSoul(agent.soul ?? "");
setJustSaved(false);
}, [agent.id, agent.soul]);
const hasChanges = soul !== (agent.soul ?? "");
const handleSave = async () => {
if (soul.length > 10000) {
addToast("Soul must be at most 10,000 characters", "error");
return;
}
setIsSaving(true);
try {
await updateAgentSoul(agent.id, soul, projectId);
addToast("Soul saved", "success");
setJustSaved(true);
setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err: any) {
addToast(`Failed to save soul: ${err.message}`, "error");
} finally {
setIsSaving(false);
}
};
return (
<div className="config-tab">
<div className="config-section">
<h3>Soul</h3>
<p className="config-description">
Define this agent&apos;s personality and identity.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-soul">Agent Soul</label>
<textarea
id="agent-soul"
className="input"
rows={12}
placeholder="Describe this agent's personality, tone, and behavioral traits..."
value={soul}
onChange={(e) => {
setSoul(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Defines the agent&apos;s character and identity. Max 10,000 characters.</span>
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Soul
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Soul saved
</span>
)}
</div>
</div>
</div>
);
}
function MemoryTab({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [memory, setMemory] = useState(agent.memory ?? "");
const [isSaving, setIsSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
useEffect(() => {
setMemory(agent.memory ?? "");
setJustSaved(false);
}, [agent.id, agent.memory]);
const isReadOnly = agent.state === "running";
const hasChanges = memory !== (agent.memory ?? "");
const handleSave = async () => {
if (memory.length > 50000) {
addToast("Memory must be at most 50,000 characters", "error");
return;
}
setIsSaving(true);
try {
await updateAgentMemory(agent.id, memory, projectId);
addToast("Memory saved", "success");
setJustSaved(true);
setTimeout(() => setJustSaved(false), 3000);
await onSaved();
} catch (err: any) {
addToast(`Failed to save memory: ${err.message}`, "error");
} finally {
setIsSaving(false);
}
};
return (
<div className="config-tab">
<div className="config-section">
<h3>Memory</h3>
<p className="config-description">
Store accumulated context and learnings for this agent.
</p>
{isReadOnly && (
<p className="config-hint" style={{ marginBottom: 12 }}>
Read-only while this agent is running.
</p>
)}
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-memory">Agent Memory</label>
<textarea
id="agent-memory"
className="input"
rows={15}
placeholder="Agent's accumulated knowledge, learnings, and preferences..."
value={memory}
readOnly={isReadOnly}
onChange={(e) => {
setMemory(e.target.value);
setJustSaved(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Per-agent memory stores learnings and context the agent has gathered. Max 50,000 characters.</span>
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving || isReadOnly}
onClick={() => void handleSave()}
>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving
</>
) : (
<>
<CheckCircle size={16} />
Save Memory
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory saved
</span>
)}
</div>
</div>
</div>
);
}
function ConfigTab({ function ConfigTab({
agent, agent,
projectId, projectId,
@@ -1797,9 +2022,9 @@ function ConfigTab({
); );
} }
// ── Children Tab ─────────────────────────────────────────────────────────── // ── Employees Tab ───────────────────────────────────────────────────────────
function ChildrenTab({ function EmployeesTab({
agentId, agentId,
projectId, projectId,
onChildClick, onChildClick,
@@ -1822,11 +2047,11 @@ function ChildrenTab({
return ( return (
<div className="detail-section"> <div className="detail-section">
<div className="detail-section-header"> <div className="detail-section-header">
<h3>Child Agents</h3> <h3>Employees</h3>
</div> </div>
<div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}> <div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}>
<Loader2 size={16} className="spin" /> <Loader2 size={16} className="spin" />
<span className="text-secondary">Loading children...</span> <span className="text-secondary">Loading employees...</span>
</div> </div>
</div> </div>
); );
@@ -1835,15 +2060,15 @@ function ChildrenTab({
return ( return (
<div className="detail-section"> <div className="detail-section">
<div className="detail-section-header"> <div className="detail-section-header">
<h3>Child Agents</h3> <h3>Employees</h3>
<span className="text-secondary">({children.length})</span> <span className="text-secondary">({children.length})</span>
</div> </div>
<div className="detail-section-body"> <div className="detail-section-body">
{children.length === 0 ? ( {children.length === 0 ? (
<div className="agent-empty" style={{ padding: 24 }}> <div className="agent-empty" style={{ padding: 24 }}>
<GitBranch size={32} opacity={0.3} /> <GitBranch size={32} opacity={0.3} />
<p>No child agents</p> <p>No employees</p>
<p className="text-secondary">This agent has no spawned children</p> <p className="text-secondary">This agent has no employees</p>
</div> </div>
) : ( ) : (
<div className="agent-tree__children"> <div className="agent-tree__children">

View File

@@ -69,8 +69,8 @@ function AgentTreeNode({
<button <button
className={`agent-tree__toggle${childCount === 0 ? " agent-tree__toggle--leaf" : ""}`} className={`agent-tree__toggle${childCount === 0 ? " agent-tree__toggle--leaf" : ""}`}
onClick={() => childCount > 0 && onToggle(agent.id)} onClick={() => childCount > 0 && onToggle(agent.id)}
title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"} title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No employees"}
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"} aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No employees"}
> >
{childCount > 0 ? ( {childCount > 0 ? (
expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} /> expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />
@@ -171,7 +171,7 @@ function OrgChartNode({
</div> </div>
</div> </div>
{children.length > 0 && ( {children.length > 0 && (
<div className="org-chart-children" role="group" aria-label={`${agent.name} reports`}> <div className="org-chart-children" role="group" aria-label={`${agent.name} employees`}>
{children.map((child) => ( {children.map((child) => (
<OrgChartNode <OrgChartNode
key={child.agent.id} key={child.agent.id}

View File

@@ -13,9 +13,13 @@ vi.mock("../../api", () => ({
deleteAgent: vi.fn(), deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(), fetchAgentLogs: vi.fn(),
fetchAgentRunLogs: vi.fn(), fetchAgentRunLogs: vi.fn(),
fetchAgentChildren: vi.fn(),
fetchAgentRuns: vi.fn(), fetchAgentRuns: vi.fn(),
fetchAgentRunDetail: vi.fn(), fetchAgentRunDetail: vi.fn(),
startAgentRun: vi.fn(), startAgentRun: vi.fn(),
updateAgentInstructions: vi.fn(),
updateAgentSoul: vi.fn(),
updateAgentMemory: vi.fn(),
fetchAgentTasks: vi.fn(), fetchAgentTasks: vi.fn(),
fetchChainOfCommand: vi.fn(), fetchChainOfCommand: vi.fn(),
})); }));
@@ -28,11 +32,12 @@ vi.mock("../AgentLogViewer", () => ({
), ),
})); }));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand } from "../../api"; import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent); const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent); const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState); const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentChildren = vi.mocked(fetchAgentChildren);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs); const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns); const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail); const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
@@ -90,6 +95,7 @@ describe("AgentDetailView", () => {
...mockAgent.completedRuns, ...mockAgent.completedRuns,
]); ]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]); mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
mockFetchAgentChildren.mockResolvedValue([]);
mockFetchAgentTasks.mockResolvedValue([]); mockFetchAgentTasks.mockResolvedValue([]);
mockFetchChainOfCommand.mockResolvedValue([mockAgent]); mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
}); });
@@ -382,11 +388,34 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Logs")).toBeInTheDocument(); expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.getByText("Runs")).toBeInTheDocument(); expect(screen.getByText("Runs")).toBeInTheDocument();
expect(screen.getByText("Tasks")).toBeInTheDocument(); expect(screen.getByText("Tasks")).toBeInTheDocument();
expect(screen.getByText("Children")).toBeInTheDocument(); expect(screen.getByText("Employees")).toBeInTheDocument();
expect(screen.getByText("Soul")).toBeInTheDocument();
expect(screen.getByText("Memory")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument(); expect(screen.getByText("Settings")).toBeInTheDocument();
}); });
}); });
it("renders Employees tab empty state", async () => {
const user = userEvent.setup();
mockFetchAgentChildren.mockResolvedValue([]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await user.click(await screen.findByText("Employees"));
await waitFor(() => {
expect(mockFetchAgentChildren).toHaveBeenCalledWith("agent-001", undefined);
expect(screen.getByText("No employees")).toBeInTheDocument();
expect(screen.getByText("This agent has no employees")).toBeInTheDocument();
});
});
it("shows Pause button for active agent", async () => { it("shows Pause button for active agent", async () => {
render( render(
<AgentDetailView <AgentDetailView

View File

@@ -68,7 +68,7 @@ function buildTree(agents: Agent[], expanded: Set<string>): AgentNode[] {
} }
/** /**
* Hook for managing agent hierarchy (parent-child relationships). * Hook for managing agent hierarchy (manager-employee relationships).
* Derives the tree structure from the `reportsTo` field on agents. * Derives the tree structure from the `reportsTo` field on agents.
* Expand/collapse state is persisted to localStorage. * Expand/collapse state is persisted to localStorage.
*/ */

View File

@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request } from "../test-request.js";
type AgentRecord = {
id: string;
name: string;
role: "executor" | "reviewer" | "triage" | "merger" | "scheduler" | "engineer" | "custom";
state: string;
createdAt: string;
updatedAt: string;
metadata: Record<string, unknown>;
reportsTo?: string;
soul?: string;
memory?: string;
};
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockGetAgent = vi.fn();
const mockUpdateAgent = vi.fn();
const mockGetAgentsByReportsTo = vi.fn();
const mockListAgents = vi.fn().mockResolvedValue([]);
vi.mock("@fusion/core", () => {
return {
AgentStore: class MockAgentStore {
init = mockInit;
getAgent = mockGetAgent;
updateAgent = mockUpdateAgent;
getAgentsByReportsTo = mockGetAgentsByReportsTo;
listAgents = mockListAgents;
},
};
});
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1171-test";
}
getFusionDir(): string {
return "/tmp/fn-1171-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
function createAgent(overrides: Partial<AgentRecord> = {}): AgentRecord {
return {
id: "agent-001",
name: "Agent One",
role: "executor",
state: "idle",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
...overrides,
};
}
describe("Agent soul/memory routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let agents: Map<string, AgentRecord>;
beforeEach(async () => {
vi.clearAllMocks();
agents = new Map<string, AgentRecord>();
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
mockGetAgent.mockImplementation(async (agentId: string) => {
return agents.get(agentId) ?? null;
});
mockUpdateAgent.mockImplementation(async (agentId: string, updates: Partial<AgentRecord>) => {
const existing = agents.get(agentId);
if (!existing) {
throw new Error(`Agent ${agentId} not found`);
}
const updated: AgentRecord = {
...existing,
...updates,
updatedAt: "2026-01-02T00:00:00.000Z",
};
agents.set(agentId, updated);
return updated;
});
mockGetAgentsByReportsTo.mockImplementation(async (agentId: string) => {
return Array.from(agents.values()).filter((agent) => agent.reportsTo === agentId);
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("GET /api/agents/:id/soul returns null when not set", async () => {
agents.set("agent-001", createAgent());
const response = await request(app, "GET", "/api/agents/agent-001/soul");
expect(response.status).toBe(200);
expect(response.body).toEqual({ soul: null });
});
it("GET /api/agents/:id/soul returns text when set", async () => {
agents.set("agent-001", createAgent({ soul: "Calm, analytical, and direct." }));
const response = await request(app, "GET", "/api/agents/agent-001/soul");
expect(response.status).toBe(200);
expect(response.body).toEqual({ soul: "Calm, analytical, and direct." });
});
it("PATCH /api/agents/:id/soul updates and returns agent", async () => {
agents.set("agent-001", createAgent());
const response = await request(
app,
"PATCH",
"/api/agents/agent-001/soul",
JSON.stringify({ soul: "Mentoring collaborator with concise feedback." }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).soul).toBe("Mentoring collaborator with concise feedback.");
expect(mockUpdateAgent).toHaveBeenCalledWith("agent-001", {
soul: "Mentoring collaborator with concise feedback.",
});
});
it("PATCH /api/agents/:id/soul rejects strings longer than 10,000 chars", async () => {
agents.set("agent-001", createAgent());
const response = await request(
app,
"PATCH",
"/api/agents/agent-001/soul",
JSON.stringify({ soul: "x".repeat(10001) }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect((response.body as any).error).toBe("soul must be at most 10,000 characters");
expect(mockUpdateAgent).not.toHaveBeenCalled();
});
it("GET /api/agents/:id/memory returns null when not set", async () => {
agents.set("agent-001", createAgent());
const response = await request(app, "GET", "/api/agents/agent-001/memory");
expect(response.status).toBe(200);
expect(response.body).toEqual({ memory: null });
});
it("PATCH /api/agents/:id/memory updates and returns agent", async () => {
agents.set("agent-001", createAgent());
const response = await request(
app,
"PATCH",
"/api/agents/agent-001/memory",
JSON.stringify({ memory: "Prefers minimal examples, avoids long prose unless requested." }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).memory).toBe("Prefers minimal examples, avoids long prose unless requested.");
expect(mockUpdateAgent).toHaveBeenCalledWith("agent-001", {
memory: "Prefers minimal examples, avoids long prose unless requested.",
});
});
it("PATCH /api/agents/:id/memory rejects strings longer than 50,000 chars", async () => {
agents.set("agent-001", createAgent());
const response = await request(
app,
"PATCH",
"/api/agents/agent-001/memory",
JSON.stringify({ memory: "x".repeat(50001) }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect((response.body as any).error).toBe("memory must be at most 50,000 characters");
expect(mockUpdateAgent).not.toHaveBeenCalled();
});
it("returns 404 for nonexistent agent on soul/memory endpoints", async () => {
const missingGetSoul = await request(app, "GET", "/api/agents/agent-missing/soul");
const missingPatchSoul = await request(
app,
"PATCH",
"/api/agents/agent-missing/soul",
JSON.stringify({ soul: "value" }),
{ "content-type": "application/json" },
);
const missingGetMemory = await request(app, "GET", "/api/agents/agent-missing/memory");
const missingPatchMemory = await request(
app,
"PATCH",
"/api/agents/agent-missing/memory",
JSON.stringify({ memory: "value" }),
{ "content-type": "application/json" },
);
expect(missingGetSoul.status).toBe(404);
expect(missingPatchSoul.status).toBe(404);
expect(missingGetMemory.status).toBe(404);
expect(missingPatchMemory.status).toBe(404);
});
it("GET /api/agents/:id/employees returns same payload as /children", async () => {
agents.set("agent-parent", createAgent({ id: "agent-parent", name: "Parent" }));
agents.set("agent-child-1", createAgent({ id: "agent-child-1", name: "Child One", reportsTo: "agent-parent" }));
agents.set("agent-child-2", createAgent({ id: "agent-child-2", name: "Child Two", reportsTo: "agent-parent" }));
const childrenResponse = await request(app, "GET", "/api/agents/agent-parent/children");
const employeesResponse = await request(app, "GET", "/api/agents/agent-parent/employees");
expect(childrenResponse.status).toBe(200);
expect(employeesResponse.status).toBe(200);
expect(employeesResponse.body).toEqual(childrenResponse.body);
expect((employeesResponse.body as any[])).toHaveLength(2);
});
});

View File

@@ -8000,6 +8000,122 @@ Output ONLY the prompt text (no markdown, no explanations).`;
} }
}); });
/**
* GET /api/agents/:id/soul
* Fetch agent soul/personality text.
*/
router.get("/agents/:id/soul", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
throw notFound("Agent not found");
}
res.json({ soul: agent.soul ?? null });
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* PATCH /api/agents/:id/soul
* Update agent soul/personality text.
* Body: { soul: string }
*/
router.patch("/agents/:id/soul", async (req, res) => {
try {
const { soul } = req.body ?? {};
if (typeof soul !== "string") {
throw badRequest("soul must be a string");
}
if (soul.length > 10000) {
throw badRequest("soul must be at most 10,000 characters");
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.updateAgent(req.params.id, { soul });
res.json(agent);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err.message?.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err);
}
});
/**
* GET /api/agents/:id/memory
* Fetch per-agent memory text.
*/
router.get("/agents/:id/memory", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
throw notFound("Agent not found");
}
res.json({ memory: agent.memory ?? null });
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* PATCH /api/agents/:id/memory
* Update per-agent memory text.
* Body: { memory: string }
*/
router.patch("/agents/:id/memory", async (req, res) => {
try {
const { memory } = req.body ?? {};
if (typeof memory !== "string") {
throw badRequest("memory must be a string");
}
if (memory.length > 50000) {
throw badRequest("memory must be at most 50,000 characters");
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.updateAgent(req.params.id, { memory });
res.json(agent);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err.message?.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err);
}
});
/** /**
* POST /api/agents/:id/state * POST /api/agents/:id/state
* Update agent state. * Update agent state.
@@ -8553,20 +8669,25 @@ Output ONLY the prompt text (no markdown, no explanations).`;
* Response 200: Agent[] — Array of agents where reportsTo equals :id * Response 200: Agent[] — Array of agents where reportsTo equals :id
* Response 404: { error: "Agent not found" } — When parent agent doesn't exist * Response 404: { error: "Agent not found" } — When parent agent doesn't exist
*/ */
router.get("/agents/:id/children", async (req, res) => { const getAgentEmployeesHandler = async (req: Request, res: Response) => {
try { try {
const scopedStore = await getScopedStore(req); const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core"); const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init(); await agentStore.init();
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!agentId) {
throw badRequest("Agent id is required");
}
// Validate the parent agent exists // Validate the parent agent exists
const parent = await agentStore.getAgent(req.params.id); const parent = await agentStore.getAgent(agentId);
if (!parent) { if (!parent) {
throw notFound("Agent not found"); throw notFound("Agent not found");
} }
const children = await agentStore.getAgentsByReportsTo(req.params.id); const children = await agentStore.getAgentsByReportsTo(agentId);
res.json(children); res.json(children);
} catch (err: any) { } catch (err: any) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -8574,7 +8695,15 @@ Output ONLY the prompt text (no markdown, no explanations).`;
} }
rethrowAsApiError(err); rethrowAsApiError(err);
} }
}); };
router.get("/agents/:id/children", getAgentEmployeesHandler);
/**
* GET /api/agents/:id/employees
* Alias for /api/agents/:id/children.
*/
router.get("/agents/:id/employees", getAgentEmployeesHandler);
// ── Agent Generation Routes ────────────────────────────────────────────── // ── Agent Generation Routes ──────────────────────────────────────────────