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:
@@ -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 */
|
||||
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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[]> {
|
||||
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => {
|
||||
// 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 */
|
||||
export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ChevronDown, ChevronRight
|
||||
} from "lucide-react";
|
||||
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 { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -50,14 +50,16 @@ interface AgentDetailViewProps {
|
||||
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 }[] = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
|
||||
{ id: "logs", label: "Logs", icon: FileText },
|
||||
{ id: "runs", label: "Runs", icon: Activity },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -407,8 +409,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "config" && (
|
||||
<ConfigTab
|
||||
{activeTab === "employees" && (
|
||||
<EmployeesTab
|
||||
agentId={agent.id}
|
||||
projectId={projectId}
|
||||
onChildClick={onChildClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "soul" && (
|
||||
<SoulTab
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
@@ -416,11 +426,21 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "children" && (
|
||||
<ChildrenTab
|
||||
agentId={agent.id}
|
||||
{activeTab === "memory" && (
|
||||
<MemoryTab
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
onChildClick={onChildClick}
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "config" && (
|
||||
<ConfigTab
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1330,6 +1350,211 @@ function validateAdvancedSettings(
|
||||
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'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'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({
|
||||
agent,
|
||||
projectId,
|
||||
@@ -1797,9 +2022,9 @@ function ConfigTab({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Children Tab ────────────────────────────────────────────────────────────
|
||||
// ── Employees Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function ChildrenTab({
|
||||
function EmployeesTab({
|
||||
agentId,
|
||||
projectId,
|
||||
onChildClick,
|
||||
@@ -1822,11 +2047,11 @@ function ChildrenTab({
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-header">
|
||||
<h3>Child Agents</h3>
|
||||
<h3>Employees</h3>
|
||||
</div>
|
||||
<div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}>
|
||||
<Loader2 size={16} className="spin" />
|
||||
<span className="text-secondary">Loading children...</span>
|
||||
<span className="text-secondary">Loading employees...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1835,15 +2060,15 @@ function ChildrenTab({
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-header">
|
||||
<h3>Child Agents</h3>
|
||||
<h3>Employees</h3>
|
||||
<span className="text-secondary">({children.length})</span>
|
||||
</div>
|
||||
<div className="detail-section-body">
|
||||
{children.length === 0 ? (
|
||||
<div className="agent-empty" style={{ padding: 24 }}>
|
||||
<GitBranch size={32} opacity={0.3} />
|
||||
<p>No child agents</p>
|
||||
<p className="text-secondary">This agent has no spawned children</p>
|
||||
<p>No employees</p>
|
||||
<p className="text-secondary">This agent has no employees</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-tree__children">
|
||||
|
||||
@@ -69,8 +69,8 @@ function AgentTreeNode({
|
||||
<button
|
||||
className={`agent-tree__toggle${childCount === 0 ? " agent-tree__toggle--leaf" : ""}`}
|
||||
onClick={() => childCount > 0 && onToggle(agent.id)}
|
||||
title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
|
||||
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
|
||||
title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No employees"}
|
||||
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No employees"}
|
||||
>
|
||||
{childCount > 0 ? (
|
||||
expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />
|
||||
@@ -171,7 +171,7 @@ function OrgChartNode({
|
||||
</div>
|
||||
</div>
|
||||
{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) => (
|
||||
<OrgChartNode
|
||||
key={child.agent.id}
|
||||
|
||||
@@ -13,9 +13,13 @@ vi.mock("../../api", () => ({
|
||||
deleteAgent: vi.fn(),
|
||||
fetchAgentLogs: vi.fn(),
|
||||
fetchAgentRunLogs: vi.fn(),
|
||||
fetchAgentChildren: vi.fn(),
|
||||
fetchAgentRuns: vi.fn(),
|
||||
fetchAgentRunDetail: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
updateAgentInstructions: vi.fn(),
|
||||
updateAgentSoul: vi.fn(),
|
||||
updateAgentMemory: vi.fn(),
|
||||
fetchAgentTasks: 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 mockUpdateAgent = vi.mocked(updateAgent);
|
||||
const mockUpdateAgentState = vi.mocked(updateAgentState);
|
||||
const mockFetchAgentChildren = vi.mocked(fetchAgentChildren);
|
||||
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
|
||||
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
|
||||
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
|
||||
@@ -90,6 +95,7 @@ describe("AgentDetailView", () => {
|
||||
...mockAgent.completedRuns,
|
||||
]);
|
||||
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
|
||||
mockFetchAgentChildren.mockResolvedValue([]);
|
||||
mockFetchAgentTasks.mockResolvedValue([]);
|
||||
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
|
||||
});
|
||||
@@ -382,11 +388,34 @@ describe("AgentDetailView", () => {
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Runs")).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();
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -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.
|
||||
* Expand/collapse state is persisted to localStorage.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* 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 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 {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
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
|
||||
const parent = await agentStore.getAgent(req.params.id);
|
||||
const parent = await agentStore.getAgent(agentId);
|
||||
if (!parent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
const children = await agentStore.getAgentsByReportsTo(req.params.id);
|
||||
const children = await agentStore.getAgentsByReportsTo(agentId);
|
||||
res.json(children);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -8574,7 +8695,15 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user