Merge branch 'fusion/fn-1449'

# Conflicts:
#	packages/cli/src/commands/__tests__/serve.test.ts
This commit is contained in:
gsxdsm
2026-04-11 19:30:33 -07:00
9 changed files with 1318 additions and 19 deletions

View File

@@ -0,0 +1,12 @@
---
"@gsxdsm/fusion": patch
---
Complete agent UI editing parity with Agent Companies manifest fields. This update:
- Adds `memory` and `bundleConfig` fields to the agent create/update API routes
- Fixes Agent Companies manifest parsing to use first-class fields (`title`, `icon`, `role`, `reportsTo`, `instructionBody` maps to `instructionsText`) instead of lossy metadata fallbacks
- Enables identity field editing (name, title, icon, role, reportsTo) in Agent Detail settings
- Adds instruction bundle configuration (mode, entry file, files, external path) to Agent Detail settings
- Adds `memory` field support to New Agent dialog and AI generation mapping
- Updates Agent Import preview to show more manifest fields (icon, reportsTo, instructions snippet)

View File

@@ -0,0 +1,941 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
const mocks = vi.hoisted(() => {
type ListenCall = {
port: number;
host?: string;
server: {
close: ReturnType<typeof vi.fn>;
address: ReturnType<typeof vi.fn>;
once: (event: string, cb: (...args: unknown[]) => void) => void;
on: (event: string, cb: (...args: unknown[]) => void) => void;
emit: (event: string, ...args: unknown[]) => boolean;
};
};
const taskStores: any[] = [];
const automationStores: any[] = [];
const agentStores: any[] = [];
const centralInstances: any[] = [];
const triageInstances: any[] = [];
const executorInstances: any[] = [];
const schedulerInstances: any[] = [];
const stuckDetectorInstances: any[] = [];
const selfHealingInstances: any[] = [];
const cronRunnerInstances: any[] = [];
const missionAutopilotInstances: any[] = [];
const missionExecutionLoopInstances: any[] = [];
const notifierInstances: any[] = [];
const pluginStoreInstances: any[] = [];
const pluginLoaderInstances: any[] = [];
const listenCalls: ListenCall[] = [];
function createTaskStoreMock() {
const emitter = new EventEmitter();
const missionStore = {
listMissions: vi.fn().mockResolvedValue([]),
};
return {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
getFusionDir: vi.fn().mockReturnValue("/repo/.fusion"),
getMissionStore: vi.fn().mockReturnValue(missionStore),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
recycleWorktrees: false,
autoMerge: false,
pollIntervalMs: 60_000,
openrouterModelSync: false,
}),
updateSettings: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
};
}
function createMockServer(port: number) {
const emitter = new EventEmitter();
return Object.assign(emitter, {
close: vi.fn((cb?: () => void) => cb?.()),
address: vi.fn(() => ({ port, family: "IPv4", address: "0.0.0.0" })),
once: emitter.once.bind(emitter),
on: emitter.on.bind(emitter),
});
}
const taskStoreCtor = vi.fn().mockImplementation(() => {
const store = createTaskStoreMock();
taskStores.push(store);
return store;
});
const automationStoreCtor = vi.fn().mockImplementation(() => {
const automationStore = {
init: vi.fn().mockResolvedValue(undefined),
};
automationStores.push(automationStore);
return automationStore;
});
const agentStoreCtor = vi.fn().mockImplementation(() => {
const agentStore = {
init: vi.fn().mockResolvedValue(undefined),
};
agentStores.push(agentStore);
return agentStore;
});
const centralCoreCtor = vi.fn().mockImplementation(() => {
const instance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
updateNode: vi.fn().mockResolvedValue(undefined),
};
centralInstances.push(instance);
return instance;
});
const createServerMock = vi.fn().mockImplementation(() => ({
listen: vi.fn((port: number, host?: string) => {
const actualPort = port === 0 ? 5050 : port;
const server = createMockServer(actualPort);
listenCalls.push({ port, host, server });
queueMicrotask(() => {
server.emit("listening");
});
return server;
}),
}));
const triageCtor = vi.fn().mockImplementation(() => {
const triage = {
start: vi.fn(),
stop: vi.fn(),
markStuckAborted: vi.fn(),
};
triageInstances.push(triage);
return triage;
});
const executorCtor = vi.fn().mockImplementation(() => {
const executor = {
resumeOrphaned: vi.fn().mockResolvedValue(undefined),
markStuckAborted: vi.fn(),
handleLoopDetected: vi.fn().mockResolvedValue(false),
recoverCompletedTask: vi.fn().mockResolvedValue(false),
getExecutingTaskIds: vi.fn().mockReturnValue(new Set()),
};
executorInstances.push(executor);
return executor;
});
const schedulerCtor = vi.fn().mockImplementation(() => {
const scheduler = {
start: vi.fn(),
stop: vi.fn(),
};
schedulerInstances.push(scheduler);
return scheduler;
});
const stuckDetectorCtor = vi.fn().mockImplementation(() => {
const detector = {
start: vi.fn(),
stop: vi.fn(),
checkNow: vi.fn().mockResolvedValue(undefined),
};
stuckDetectorInstances.push(detector);
return detector;
});
const selfHealingCtor = vi.fn().mockImplementation(() => {
const manager = {
start: vi.fn(),
stop: vi.fn(),
checkStuckBudget: vi.fn().mockResolvedValue(true),
};
selfHealingInstances.push(manager);
return manager;
});
const cronRunnerCtor = vi.fn().mockImplementation(() => {
const cron = {
start: vi.fn(),
stop: vi.fn(),
};
cronRunnerInstances.push(cron);
return cron;
});
const missionAutopilotCtor = vi.fn().mockImplementation(() => {
const autopilot = {
start: vi.fn(),
stop: vi.fn(),
setScheduler: vi.fn(),
};
missionAutopilotInstances.push(autopilot);
return autopilot;
});
const missionExecutionLoopCtor = vi.fn().mockImplementation(() => {
const loop = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
processTaskOutcome: vi.fn().mockResolvedValue(undefined),
recoverActiveMissions: vi.fn().mockResolvedValue(undefined),
};
missionExecutionLoopInstances.push(loop);
return loop;
});
const notifierCtor = vi.fn().mockImplementation(() => {
const notifier = {
start: vi.fn(),
stop: vi.fn(),
};
notifierInstances.push(notifier);
return notifier;
});
const pluginStoreCtor = vi.fn().mockImplementation(() => {
const pluginStore = {
init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
unregisterPlugin: vi.fn(),
updatePluginState: vi.fn(),
};
pluginStoreInstances.push(pluginStore);
return pluginStore;
});
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
};
pluginLoaderInstances.push(pluginLoader);
return pluginLoader;
});
const authStorage = {
getApiKey: vi.fn().mockResolvedValue(undefined),
};
const modelRegistry = {
registerProvider: vi.fn(),
refresh: vi.fn(),
};
return {
taskStores,
automationStores,
agentStores,
centralInstances,
triageInstances,
executorInstances,
schedulerInstances,
stuckDetectorInstances,
selfHealingInstances,
cronRunnerInstances,
missionAutopilotInstances,
missionExecutionLoopInstances,
notifierInstances,
listenCalls,
taskStoreCtor,
automationStoreCtor,
agentStoreCtor,
centralCoreCtor,
createServerMock,
triageCtor,
executorCtor,
schedulerCtor,
stuckDetectorCtor,
selfHealingCtor,
cronRunnerCtor,
missionAutopilotCtor,
missionExecutionLoopCtor,
notifierCtor,
pluginStoreCtor,
pluginLoaderCtor,
authStorage,
modelRegistry,
reset() {
taskStores.length = 0;
automationStores.length = 0;
agentStores.length = 0;
centralInstances.length = 0;
triageInstances.length = 0;
executorInstances.length = 0;
schedulerInstances.length = 0;
stuckDetectorInstances.length = 0;
selfHealingInstances.length = 0;
cronRunnerInstances.length = 0;
missionAutopilotInstances.length = 0;
missionExecutionLoopInstances.length = 0;
notifierInstances.length = 0;
pluginStoreInstances.length = 0;
pluginLoaderInstances.length = 0;
listenCalls.length = 0;
},
};
});
vi.mock("@fusion/core", () => ({
TaskStore: mocks.taskStoreCtor,
AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor,
CentralCore: mocks.centralCoreCtor,
PluginStore: mocks.pluginStoreCtor,
PluginLoader: mocks.pluginLoaderCtor,
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: vi.fn().mockResolvedValue(undefined),
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: vi.fn().mockResolvedValue({
generatedAt: new Date().toISOString(),
health: "healthy",
checks: [],
workingMemory: { exists: true, size: 100, sectionCount: 2 },
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
}),
}));
vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServerMock,
GitHubClient: vi.fn().mockImplementation(() => ({})),
}));
vi.mock("@fusion/engine", () => ({
TriageProcessor: mocks.triageCtor,
TaskExecutor: mocks.executorCtor,
Scheduler: mocks.schedulerCtor,
AgentSemaphore: vi.fn().mockImplementation(() => ({
run: (fn: () => Promise<unknown>) => fn(),
})),
WorktreePool: vi.fn().mockImplementation(() => ({
rehydrate: vi.fn(),
})),
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
UsageLimitPauser: vi.fn().mockImplementation(() => ({})),
PRIORITY_MERGE: 100,
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
NtfyNotifier: mocks.notifierCtor,
PrMonitor: vi.fn().mockImplementation(() => ({
onNewComments: vi.fn(),
})),
PrCommentHandler: vi.fn().mockImplementation(() => ({
handleNewComments: vi.fn(),
createFollowUpTask: vi.fn().mockResolvedValue(undefined),
})),
CronRunner: mocks.cronRunnerCtor,
StuckTaskDetector: mocks.stuckDetectorCtor,
SelfHealingManager: mocks.selfHealingCtor,
MissionAutopilot: mocks.missionAutopilotCtor,
MissionExecutionLoop: mocks.missionExecutionLoopCtor,
createAiPromptExecutor: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok")),
HeartbeatMonitor: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
})),
HeartbeatTriggerScheduler: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
registerAgent: vi.fn(),
getRegisteredAgents: vi.fn().mockReturnValue([]),
})),
}));
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
create: vi.fn(() => mocks.authStorage),
},
DefaultPackageManager: vi.fn().mockImplementation(() => ({
resolve: vi.fn().mockResolvedValue({ extensions: [] }),
})),
ModelRegistry: vi.fn().mockImplementation(() => mocks.modelRegistry),
SettingsManager: {
create: vi.fn(() => ({})),
},
discoverAndLoadExtensions: vi.fn().mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
}),
getAgentDir: vi.fn(() => "/mock-agent-dir"),
createExtensionRuntime: vi.fn(),
}));
vi.mock("../port-prompt.js", () => ({
promptForPort: vi.fn(async (port: number) => port),
}));
vi.mock("../task-lifecycle.js", () => ({
getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"),
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
}));
const { runServe } = await import("../serve.js");
describe("runServe", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("initializes stores, starts engine services, and creates a headless server", async () => {
await runServe(4040, {});
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
expect(mocks.taskStores[0].init).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].watch).toHaveBeenCalledTimes(1);
expect(mocks.automationStoreCtor).toHaveBeenCalledWith("/repo");
expect(mocks.automationStores[0].init).toHaveBeenCalledTimes(1);
expect(mocks.agentStores[0].init).toHaveBeenCalledTimes(1);
expect(mocks.createServerMock).toHaveBeenCalledTimes(1);
expect(mocks.createServerMock.mock.calls[0][1]).toMatchObject({
headless: true,
});
expect(mocks.triageInstances[0].start).toHaveBeenCalledTimes(1);
expect(mocks.schedulerInstances[0].start).toHaveBeenCalledTimes(1);
expect(mocks.missionAutopilotInstances[0].start).toHaveBeenCalledTimes(1);
expect(mocks.stuckDetectorInstances[0].start).toHaveBeenCalledTimes(1);
expect(mocks.selfHealingInstances[0].start).toHaveBeenCalledTimes(1);
expect(mocks.executorInstances[0].resumeOrphaned).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("sets enginePaused when started with paused=true", async () => {
await runServe(0, { paused: true });
expect(mocks.taskStores[0].updateSettings).toHaveBeenCalledWith({ enginePaused: true });
await triggerSignal("SIGTERM");
});
it("updates the local node status online on startup and offline on shutdown", async () => {
await runServe(4040, {});
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
expect(nodeCentral).toBeDefined();
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "online" });
await triggerSignal("SIGINT");
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "offline" });
});
it("stops engine services during shutdown", async () => {
await runServe(4040, {});
const listenCall = mocks.listenCalls[0];
expect(listenCall).toBeDefined();
await triggerSignal("SIGTERM");
expect(mocks.selfHealingInstances[0].stop).toHaveBeenCalledTimes(1);
expect(mocks.stuckDetectorInstances[0].stop).toHaveBeenCalledTimes(1);
expect(mocks.missionAutopilotInstances[0].stop).toHaveBeenCalledTimes(1);
expect(mocks.triageInstances[0].stop).toHaveBeenCalledTimes(1);
expect(mocks.schedulerInstances[0].stop).toHaveBeenCalledTimes(1);
expect(mocks.cronRunnerInstances[0].stop).toHaveBeenCalledTimes(1);
expect(mocks.notifierInstances[0].stop).toHaveBeenCalledTimes(1);
expect(listenCall.server.close).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].close).toHaveBeenCalledTimes(1);
});
it("listens on 0.0.0.0 by default and respects a custom host", async () => {
await runServe(3010, {});
expect(mocks.listenCalls[0]).toMatchObject({
port: 3010,
host: "0.0.0.0",
});
await triggerSignal("SIGINT");
await runServe(3020, { host: "127.0.0.1" });
expect(mocks.listenCalls[1]).toMatchObject({
port: 3020,
host: "127.0.0.1",
});
await triggerSignal("SIGINT");
});
});
describe("runServe — Plugin wiring", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let logSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
});
afterEach(() => {
logSpy.mockRestore();
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("creates PluginStore and PluginLoader instances", async () => {
const { PluginStore, PluginLoader } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginStore).toHaveBeenCalledTimes(1);
expect(PluginLoader).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("passes pluginStore, pluginLoader, and pluginRunner to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts).toHaveProperty("pluginStore");
expect(serverOpts).toHaveProperty("pluginLoader");
expect(serverOpts).toHaveProperty("pluginRunner");
expect(serverOpts.pluginRunner).toBe(serverOpts.pluginLoader);
await triggerSignal("SIGINT");
});
it("initializes PluginStore with the task store's fusion directory", async () => {
const { PluginStore } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginStore).toHaveBeenCalledWith("/repo/.fusion");
await triggerSignal("SIGINT");
});
it("initializes PluginLoader with pluginStore and taskStore", async () => {
const { PluginLoader } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginLoader).toHaveBeenCalledTimes(1);
const loaderOptions = PluginLoader.mock.calls[0][0];
expect(loaderOptions).toHaveProperty("pluginStore");
expect(loaderOptions).toHaveProperty("taskStore");
await triggerSignal("SIGINT");
});
it("includes plugin wiring in headless server", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts.headless).toBe(true);
expect(serverOpts.pluginStore).toBeDefined();
expect(serverOpts.pluginLoader).toBeDefined();
await triggerSignal("SIGINT");
});
});
describe("runServe — Memory Insight Automation wiring", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("syncs insight extraction automation on startup", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
expect(syncInsightExtractionAutomation).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
maxConcurrent: 2,
recycleWorktrees: false,
autoMerge: false,
pollIntervalMs: 60_000,
}),
);
await triggerSignal("SIGINT");
});
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
await runServe(4040, {});
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
await triggerSignal("SIGINT");
});
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
// Simulate settings update
syncInsightExtractionAutomation.mockClear();
mocks.taskStores[0].emit("settings:updated", {
settings: {
insightExtractionEnabled: true,
insightExtractionSchedule: "0 3 * * *",
},
previous: {
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
},
});
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
// Simulate unrelated settings update
syncInsightExtractionAutomation.mockClear();
mocks.taskStores[0].emit("settings:updated", {
settings: {
maxConcurrent: 5,
},
previous: {
maxConcurrent: 2,
},
});
expect(syncInsightExtractionAutomation).not.toHaveBeenCalled();
await triggerSignal("SIGINT");
});
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
syncInsightExtractionAutomation.mockRejectedValueOnce(new Error("Sync failed"));
await runServe(4040, {});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
);
consoleSpy.mockRestore();
await triggerSignal("SIGINT");
});
});
describe("runServe — Semaphore boundary (task lanes only)", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
signalHandlers = { SIGINT: [], SIGTERM: [] };
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
});
afterEach(() => {
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("passes semaphore to TriageProcessor (task lane)", async () => {
await runServe(4040, {});
expect(mocks.triageCtor).toHaveBeenCalledTimes(1);
const triageOptions = mocks.triageCtor.mock.calls[0][2];
expect(triageOptions).toHaveProperty("semaphore");
expect(triageOptions.semaphore).toBeDefined();
await triggerSignal("SIGINT");
});
it("passes semaphore to TaskExecutor (task lane)", async () => {
await runServe(4040, {});
expect(mocks.executorCtor).toHaveBeenCalledTimes(1);
const executorOptions = mocks.executorCtor.mock.calls[0][2];
expect(executorOptions).toHaveProperty("semaphore");
expect(executorOptions.semaphore).toBeDefined();
await triggerSignal("SIGINT");
});
it("passes semaphore to Scheduler (task lane)", async () => {
await runServe(4040, {});
expect(mocks.schedulerCtor).toHaveBeenCalledTimes(1);
const schedulerOptions = mocks.schedulerCtor.mock.calls[0][1];
expect(schedulerOptions).toHaveProperty("semaphore");
expect(schedulerOptions.semaphore).toBeDefined();
await triggerSignal("SIGINT");
});
it("creates shared semaphore instance for task lanes", async () => {
await runServe(4040, {});
// Get the semaphore instance from each component
const triageSemaphore = mocks.triageCtor.mock.calls[0][2].semaphore;
const executorSemaphore = mocks.executorCtor.mock.calls[0][2].semaphore;
const schedulerSemaphore = mocks.schedulerCtor.mock.calls[0][1].semaphore;
// All should reference the same semaphore instance
expect(triageSemaphore).toBe(executorSemaphore);
expect(executorSemaphore).toBe(schedulerSemaphore);
await triggerSignal("SIGINT");
});
it("does NOT pass semaphore to HeartbeatMonitor (utility path)", async () => {
const { HeartbeatMonitor } = await import("@fusion/engine");
await runServe(4040, {});
expect(HeartbeatMonitor).toHaveBeenCalledTimes(1);
const heartbeatOptions = HeartbeatMonitor.mock.calls[0][0];
expect(heartbeatOptions).not.toHaveProperty("semaphore");
await triggerSignal("SIGINT");
});
it("does NOT pass semaphore to HeartbeatTriggerScheduler (utility path)", async () => {
const { HeartbeatTriggerScheduler } = await import("@fusion/engine");
await runServe(4040, {});
expect(HeartbeatTriggerScheduler).toHaveBeenCalledTimes(1);
// HeartbeatTriggerScheduler takes 2-3 args: (agentStore, callback, taskStore?)
const triggerArgs = HeartbeatTriggerScheduler.mock.calls[0];
// Semaphore should NOT be in any of the arguments (it would have _active property)
expect(triggerArgs).not.toContainEqual(expect.objectContaining({ _active: expect.any(Number) }));
await triggerSignal("SIGINT");
});
it("does NOT pass semaphore to CronRunner (utility path)", async () => {
await runServe(4040, {});
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
// CronRunner takes (taskStore, automationStore, options)
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
expect(cronOptions).not.toHaveProperty("semaphore");
await triggerSignal("SIGINT");
});
it("calls createAiPromptExecutor with cwd only (no semaphore)", async () => {
const { createAiPromptExecutor } = await import("@fusion/engine");
await runServe(4040, {});
expect(createAiPromptExecutor).toHaveBeenCalledTimes(1);
// createAiPromptExecutor takes only cwd parameter
expect(createAiPromptExecutor).toHaveBeenCalledWith(expect.any(String));
const calledWith = createAiPromptExecutor.mock.calls[0];
// Should be called with exactly one argument (cwd)
expect(calledWith.length).toBe(1);
await triggerSignal("SIGINT");
});
it("onMerge uses semaphore.run() to gate merge execution (task lane)", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
// The onMerge function is passed to createServer and should use semaphore.run()
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts).toHaveProperty("onMerge");
expect(typeof serverOpts.onMerge).toBe("function");
// The onMerge function should be a wrapper that uses semaphore.run()
// We can't directly test the internals, but we verified semaphore is passed to
// the same instance used by triage/executor/scheduler above
await triggerSignal("SIGINT");
});
});

View File

@@ -4,6 +4,51 @@
Fusion uses multiple agent roles for triage, execution, review, and merge workflows.
## Agent Field Parity Matrix
Every first-class editable agent field has a defined create/edit/import/template behavior. This ensures consistent round-tripping across all surfaces.
### Agent Model Fields
| Field | Create | Edit | Import | Notes |
|-------|--------|------|--------|-------|
| `name` | ✓ | ✓ | ✓ (from manifest) | Unique identifier |
| `role` | ✓ | ✓ | ✓ (mapped from manifest) | Agent capability |
| `metadata` | ✓ | ✓ | ✓ | Arbitrary key-value data |
| `title` | ✓ | ✓ | ✓ (from manifest) | Job title/description |
| `icon` | ✓ | ✓ | ✓ (from manifest) | Emoji or icon identifier |
| `reportsTo` | ✓ | ✓ | ✓ (from manifest) | Parent agent ID |
| `runtimeConfig` | ✓ | ✓ | ✗ | Heartbeat/budget config |
| `permissions` | ✓ | ✓ | ✗ | Capability flags |
| `instructionsPath` | ✓ | ✓ | ✗ | File-backed instructions path |
| `instructionsText` | ✓ | ✓ | ✓ (from manifest `instructionBody`) | Inline instructions |
| `soul` | ✓ | ✓ | ✗ | Personality/identity description |
| `memory` | ✓ | ✓ | ✗ | Per-agent accumulated knowledge |
| `bundleConfig` | ✓ | ✓ | ✗ | Structured instruction bundle |
### Agent Companies Manifest Fields
| Manifest Field | First-Class Agent Field | Fallback |
|---------------|------------------------|----------|
| `name` | `name` | — (required) |
| `title` | `title` | — |
| `icon` | `icon` | — |
| `role` | `role` (mapped to AgentCapability) | `custom` |
| `reportsTo` | `reportsTo` | — |
| `instructionBody` | `instructionsText` | — |
| `skills` | `metadata.skills` | — |
### System-Managed Fields (Not User-Editable)
These fields are managed by the engine and cannot be directly edited:
- `id` — Auto-generated unique identifier
- `state` — Agent lifecycle state (managed by engine)
- `taskId` — Current working task (managed by scheduler)
- `totalInputTokens` / `totalOutputTokens` — Token usage totals (managed by engine)
- `createdAt` / `updatedAt` / `lastHeartbeatAt` — Timestamps (managed by system)
- `lastError` — Last error message (managed by engine)
## Agents View (Dashboard)
The agents surface provides:

View File

@@ -357,10 +357,9 @@ name: Zip CEO
name: "CEO",
role: "custom",
title: "Chief Executive Officer",
instructionsText: "Lead strategy",
metadata: {
instructions: "Lead strategy",
skills: ["review"],
reportsTo: null,
sources: [{ kind: "git", repo: "acme/repo" }],
},
});
@@ -391,6 +390,45 @@ name: Zip CEO
const input = agentManifestToAgentCreateInput({ name: "Generalist" });
expect(input.role).toBe("custom");
});
it("maps manifest icon to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Bot",
icon: "🤖",
role: "executor",
});
expect(input).toEqual({
name: "Bot",
role: "executor",
icon: "🤖",
});
});
it("maps manifest reportsTo to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Worker",
reportsTo: "manager-001",
});
expect(input).toEqual({
name: "Worker",
role: "custom",
reportsTo: "manager-001",
});
});
it("maps manifest role to first-class field", () => {
const input = agentManifestToAgentCreateInput({
name: "Reviewer",
role: "reviewer",
});
expect(input).toEqual({
name: "Reviewer",
role: "reviewer",
});
});
});
describe("mapRoleToCapability", () => {

View File

@@ -284,25 +284,29 @@ export async function parseCompanyArchive(archivePath: string): Promise<AgentCom
export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCreateInput {
const metadata: Record<string, unknown> = {};
if (typeof agent.instructionBody === "string") {
metadata.instructions = agent.instructionBody;
}
// Store skills and metadata sources in metadata (skills is not a first-class field)
if (Array.isArray(agent.skills) && agent.skills.length > 0) {
metadata.skills = agent.skills;
}
if (agent.reportsTo !== undefined) {
metadata.reportsTo = agent.reportsTo;
}
if (Array.isArray(agent.metadata?.sources) && agent.metadata.sources.length > 0) {
metadata.sources = agent.metadata.sources;
}
return {
name: agent.name,
role: mapRoleToCapability("custom"),
role: agent.role ? mapRoleToCapability(agent.role) : mapRoleToCapability("custom"),
...(typeof agent.title === "string" && agent.title.trim().length > 0
? { title: agent.title }
: {}),
...(typeof agent.icon === "string" && agent.icon.trim().length > 0
? { icon: agent.icon.trim() }
: {}),
...(typeof agent.reportsTo === "string" && agent.reportsTo.trim().length > 0
? { reportsTo: agent.reportsTo.trim() }
: {}),
...(typeof agent.instructionBody === "string" && agent.instructionBody.trim().length > 0
? { instructionsText: agent.instructionBody.trim() }
: {}),
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
};
}

View File

@@ -2165,6 +2165,13 @@ function ConfigTab({
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
// Identity field state
const [nameValue, setNameValue] = useState(agent.name);
const [roleValue, setRoleValue] = useState(agent.role);
const [titleValue, setTitleValue] = useState(agent.title ?? "");
const [iconValue, setIconValue] = useState(agent.icon ?? "");
const [reportsToValue, setReportsToValue] = useState(agent.reportsTo ?? "");
// Local form state initialised from agent.metadata
const [formValues, setFormValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
@@ -2218,6 +2225,12 @@ function ConfigTab({
return initial;
});
// Bundle config state
const [bundleMode, setBundleMode] = useState<string>(agent.bundleConfig?.mode ?? "");
const [bundleEntryFile, setBundleEntryFile] = useState(agent.bundleConfig?.entryFile ?? "AGENTS.md");
const [bundleExternalPath, setBundleExternalPath] = useState(agent.bundleConfig?.externalPath ?? "");
const [bundleFiles, setBundleFiles] = useState<string[]>(agent.bundleConfig?.files ?? []);
// Budget status for progress bar display
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const [isResettingBudget, setIsResettingBudget] = useState(false);
@@ -2250,6 +2263,19 @@ function ConfigTab({
/** Detect whether any local value differs from the persisted metadata */
const hasChanges = (() => {
// Check identity fields
if (nameValue !== agent.name) return true;
if (roleValue !== agent.role) return true;
if (titleValue !== (agent.title ?? "")) return true;
if (iconValue !== (agent.icon ?? "")) return true;
if (reportsToValue !== (agent.reportsTo ?? "")) return true;
// Check bundle config
if (bundleMode !== (agent.bundleConfig?.mode ?? "")) return true;
if (bundleEntryFile !== (agent.bundleConfig?.entryFile ?? "AGENTS.md")) return true;
if (bundleExternalPath !== (agent.bundleConfig?.externalPath ?? "")) return true;
if (JSON.stringify(bundleFiles) !== JSON.stringify(agent.bundleConfig?.files ?? [])) return true;
for (const field of ADVANCED_SETTINGS) {
const current = formValues[field.key]?.trim() ?? "";
const persisted = agent.metadata[field.key] !== undefined && agent.metadata[field.key] !== null
@@ -2454,9 +2480,31 @@ function ConfigTab({
delete newRuntimeConfig.budgetConfig;
}
// Build bundleConfig payload — only include if mode is set
let newBundleConfig: { mode: "managed" | "external"; entryFile: string; files: string[]; externalPath?: string } | undefined;
if (bundleMode) {
newBundleConfig = {
mode: bundleMode as "managed" | "external",
entryFile: bundleEntryFile || "AGENTS.md",
files: bundleFiles.length > 0 ? bundleFiles : ["AGENTS.md"],
};
if (bundleMode === "external" && bundleExternalPath.trim()) {
newBundleConfig.externalPath = bundleExternalPath.trim();
}
}
setIsSaving(true);
try {
await updateAgent(agent.id, { metadata: newMetadata, runtimeConfig: newRuntimeConfig }, projectId);
await updateAgent(agent.id, {
name: nameValue.trim() || undefined,
role: roleValue as any,
title: titleValue.trim() || undefined,
icon: iconValue.trim() || undefined,
reportsTo: reportsToValue.trim() || undefined,
metadata: newMetadata,
runtimeConfig: newRuntimeConfig,
bundleConfig: newBundleConfig,
}, projectId);
addToast("Settings saved", "success");
setJustSaved(true);
// Auto-hide the saved indicator after 3 seconds
@@ -2479,19 +2527,24 @@ function ConfigTab({
<div className="config-fields">
<div className="config-field">
<label>Name</label>
<label htmlFor="agent-name">Name</label>
<input
id="agent-name"
type="text"
className="input"
defaultValue={agent.name}
disabled
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
/>
<span className="config-hint">Name changes coming soon</span>
</div>
<div className="config-field">
<label>Role</label>
<select className="select" defaultValue={agent.role} disabled>
<label htmlFor="agent-role">Role</label>
<select
id="agent-role"
className="select"
value={roleValue}
onChange={(e) => setRoleValue(e.target.value as any)}
>
<option value="triage">Triage</option>
<option value="executor">Executor</option>
<option value="reviewer">Reviewer</option>
@@ -2499,7 +2552,42 @@ function ConfigTab({
<option value="scheduler">Scheduler</option>
<option value="custom">Custom</option>
</select>
<span className="config-hint">Role changes coming soon</span>
</div>
<div className="config-field">
<label htmlFor="agent-title">Title</label>
<input
id="agent-title"
type="text"
className="input"
placeholder="e.g. Senior Code Reviewer"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
/>
</div>
<div className="config-field">
<label htmlFor="agent-icon">Icon</label>
<input
id="agent-icon"
type="text"
className="input"
placeholder="e.g. 🤖"
value={iconValue}
onChange={(e) => setIconValue(e.target.value)}
/>
</div>
<div className="config-field">
<label htmlFor="agent-reports-to">Reports To</label>
<input
id="agent-reports-to"
type="text"
className="input"
placeholder="e.g. agent-001"
value={reportsToValue}
onChange={(e) => setReportsToValue(e.target.value)}
/>
</div>
</div>
</div>
@@ -2723,6 +2811,81 @@ function ConfigTab({
</div>
</div>
<div className="config-section">
<h3>Instruction Bundle</h3>
<p className="config-description">
Configure the agent's instruction bundle. Leave empty to use inline instructions only.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="bundle-mode">Bundle Mode</label>
<select
id="bundle-mode"
className="select"
value={bundleMode}
onChange={(e) => setBundleMode(e.target.value)}
>
<option value="">None (use inline instructions)</option>
<option value="managed">Managed (system-managed directory)</option>
<option value="external">External (user-specified path)</option>
</select>
<span className="config-hint">
{bundleMode === "managed" && "Files will be stored in a system-managed directory within .fusion/agents/"}
{bundleMode === "external" && "Specify an external directory path for the instruction files"}
{!bundleMode && "Select a mode to enable instruction bundling"}
</span>
</div>
{bundleMode && (
<>
<div className="config-field">
<label htmlFor="bundle-entry-file">Entry File</label>
<input
id="bundle-entry-file"
type="text"
className="input"
placeholder="AGENTS.md"
value={bundleEntryFile}
onChange={(e) => setBundleEntryFile(e.target.value)}
/>
<span className="config-hint">Primary instructions file name (default: AGENTS.md)</span>
</div>
{bundleMode === "external" && (
<div className="config-field">
<label htmlFor="bundle-external-path">External Path</label>
<input
id="bundle-external-path"
type="text"
className="input"
placeholder="e.g. .fusion/agents/my-agent"
value={bundleExternalPath}
onChange={(e) => setBundleExternalPath(e.target.value)}
/>
<span className="config-hint">Absolute or relative path to the external directory</span>
</div>
)}
<div className="config-field">
<label htmlFor="bundle-files">Files (comma-separated)</label>
<input
id="bundle-files"
type="text"
className="input"
placeholder="AGENTS.md, PROMPTS.md"
value={bundleFiles.join(", ")}
onChange={(e) => setBundleFiles(
e.target.value.split(",").map(f => f.trim()).filter(Boolean)
)}
/>
<span className="config-hint">List of file names in the bundle directory</span>
</div>
</>
)}
</div>
</div>
<div className="config-section">
<h3>Advanced Settings</h3>
<p className="config-description">

View File

@@ -13,6 +13,9 @@ interface AgentPreview {
name: string;
role: string;
title?: string;
icon?: string;
reportsTo?: string;
instructionsText?: string;
skills?: string[];
}
@@ -28,6 +31,9 @@ interface ImportResult {
interface DirectoryAgentInput {
name: string;
title?: string;
icon?: string;
role?: string;
reportsTo?: string;
skills?: string[];
instructionBody?: string;
}
@@ -75,6 +81,9 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
if (key === "name") result.name = normalizedValue;
if (key === "title") result.title = normalizedValue;
if (key === "icon") result.icon = normalizedValue;
if (key === "role") result.role = normalizedValue;
if (key === "reportsTo") result.reportsTo = normalizedValue;
}
if (!result.name) {
@@ -391,16 +400,24 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<div className="agent-import-agent-list">
{agents.map((agent, idx) => (
<div key={idx} className="agent-import-agent-item">
<span className="agent-import-agent-icon">🤖</span>
<span className="agent-import-agent-icon">{agent.icon || "🤖"}</span>
<div className="agent-import-agent-details">
<span className="agent-import-agent-name">{agent.name}</span>
<span className="agent-import-agent-meta">
{agent.title && <span className="agent-import-agent-title">{agent.title} · </span>}
<span className="agent-import-agent-role">{agent.role}</span>
{agent.reportsTo && (
<span className="agent-import-agent-reports"> · reports to {agent.reportsTo}</span>
)}
{agent.skills && agent.skills.length > 0 && (
<span className="agent-import-agent-model"> · {agent.skills.join(", ")}</span>
<span className="agent-import-agent-model"> · skills: {agent.skills.join(", ")}</span>
)}
</span>
{agent.instructionsText && (
<span className="agent-import-agent-instructions">
{agent.instructionsText.slice(0, 100)}{agent.instructionsText.length > 100 ? "..." : ""}
</span>
)}
</div>
</div>
))}

View File

@@ -44,6 +44,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
const [instructionsPath, setInstructionsPath] = useState("");
const [instructionsText, setInstructionsText] = useState("");
const [soul, setSoul] = useState("");
const [memory, setMemory] = useState("");
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
model: "",
thinkingLevel: "off",
@@ -90,6 +91,8 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
setTitle(spec.description);
setIcon(spec.icon);
setRole(mappedRole);
// Map generated systemPrompt to instructionsText
setInstructionsText(spec.systemPrompt);
setRuntimeConfig(c => ({
...c,
thinkingLevel: spec.thinkingLevel,
@@ -147,6 +150,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
setInstructionsPath("");
setInstructionsText("");
setSoul("");
setMemory("");
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
setSelectedPresetId(null);
setError(null);
@@ -172,6 +176,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}),
...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}),
...(soul.trim() ? { soul: soul.trim() } : {}),
...(memory.trim() ? { memory: memory.trim() } : {}),
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
}, projectId);
handleClose();
@@ -300,6 +305,17 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
onChange={e => setSoul(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-memory">Memory <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-memory"
className="input"
rows={2}
placeholder="Per-agent memory — stores learnings and context the agent has gathered..."
value={memory}
onChange={e => setMemory(e.target.value)}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
<input

View File

@@ -9034,6 +9034,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
instructionsPath,
instructionsText,
soul,
memory,
bundleConfig,
} = req.body ?? {};
if (!name || typeof name !== "string") {
@@ -9069,6 +9071,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (typeof soul === "string" && soul.length > 10000) {
throw badRequest("soul must be at most 10,000 characters");
}
if (memory !== undefined && memory !== null && typeof memory !== "string") {
throw badRequest("memory must be a string");
}
if (typeof memory === "string" && memory.length > 50000) {
throw badRequest("memory must be at most 50,000 characters");
}
if (bundleConfig !== undefined && bundleConfig !== null) {
if (typeof bundleConfig !== "object" || Array.isArray(bundleConfig)) {
throw badRequest("bundleConfig must be an object");
}
if (typeof bundleConfig.mode !== "string" || !["managed", "external"].includes(bundleConfig.mode)) {
throw badRequest("bundleConfig.mode must be 'managed' or 'external'");
}
if (typeof bundleConfig.entryFile !== "string") {
throw badRequest("bundleConfig.entryFile must be a string");
}
if (!Array.isArray(bundleConfig.files)) {
throw badRequest("bundleConfig.files must be an array");
}
if (bundleConfig.externalPath !== undefined && typeof bundleConfig.externalPath !== "string") {
throw badRequest("bundleConfig.externalPath must be a string");
}
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
@@ -9087,6 +9112,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
instructionsPath: instructionsPath ?? undefined,
instructionsText: instructionsText ?? undefined,
soul: soul ?? undefined,
memory: memory ?? undefined,
bundleConfig: bundleConfig ?? undefined,
});
res.status(201).json(agent);
} catch (err: any) {
@@ -9258,6 +9285,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
name: input.name,
role: input.role,
title: typeof input.title === "string" ? input.title : undefined,
icon: typeof input.icon === "string" ? input.icon : undefined,
reportsTo: typeof input.reportsTo === "string" ? input.reportsTo : undefined,
instructionsText: typeof input.instructionsText === "string"
? input.instructionsText.slice(0, 200) + (input.instructionsText.length > 200 ? "..." : "")
: undefined,
skills: Array.isArray(input.metadata?.skills)
? input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
: undefined,
@@ -9526,6 +9558,37 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
updates.soul = body.soul ?? undefined;
}
if ("memory" in body) {
if (body.memory !== null && typeof body.memory !== "string") {
throw badRequest("memory must be a string");
}
if (typeof body.memory === "string" && body.memory.length > 50000) {
throw badRequest("memory must be at most 50,000 characters");
}
updates.memory = body.memory ?? undefined;
}
if ("bundleConfig" in body) {
if (body.bundleConfig !== null) {
if (typeof body.bundleConfig !== "object" || Array.isArray(body.bundleConfig)) {
throw badRequest("bundleConfig must be an object");
}
if (typeof body.bundleConfig.mode !== "string" || !["managed", "external"].includes(body.bundleConfig.mode)) {
throw badRequest("bundleConfig.mode must be 'managed' or 'external'");
}
if (typeof body.bundleConfig.entryFile !== "string") {
throw badRequest("bundleConfig.entryFile must be a string");
}
if (!Array.isArray(body.bundleConfig.files)) {
throw badRequest("bundleConfig.files must be an array");
}
if (body.bundleConfig.externalPath !== undefined && typeof body.bundleConfig.externalPath !== "string") {
throw badRequest("bundleConfig.externalPath must be a string");
}
}
updates.bundleConfig = body.bundleConfig ?? undefined;
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });