Merge branch 'fusion/fn-1449'
# Conflicts: # packages/cli/src/commands/__tests__/serve.test.ts
This commit is contained in:
12
.changeset/fn-1449-agent-ui-parity.md
Normal file
12
.changeset/fn-1449-agent-ui-parity.md
Normal 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)
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user