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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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), {
|
||||||
|
|||||||
@@ -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'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({
|
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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
* 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 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user