feat(FN-1085): align agent routing and runtime contracts
- Harden core AgentStore lifecycle behavior and heartbeat runtime integration paths - Align dashboard agent APIs, server routes, and agent UI flows with the updated contract - Tighten CLI agent/message command routing and validate payload handling semantics - Expand test coverage across core, dashboard, engine, and CLI for route, heartbeat, and instruction regressions
This commit is contained in:
@@ -907,6 +907,8 @@ describe("refineTask", () => {
|
||||
|
||||
import {
|
||||
startAgentRun,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
@@ -922,6 +924,71 @@ import {
|
||||
pushBranch,
|
||||
} from "./api";
|
||||
|
||||
describe("agent API wrappers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("creates agents with full create payload and project scope", async () => {
|
||||
const createdAgent = { id: "agent-001", name: "reviewer", role: "reviewer", state: "idle" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, createdAgent, 201));
|
||||
|
||||
await createAgent({
|
||||
name: "reviewer",
|
||||
role: "reviewer",
|
||||
title: "Review Agent",
|
||||
icon: "🔍",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatIntervalMs: 15000, maxConcurrentRuns: 2 },
|
||||
permissions: { read: true, write: false },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Prioritize security and edge cases.",
|
||||
}, "proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents?projectId=proj_123", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "reviewer",
|
||||
role: "reviewer",
|
||||
title: "Review Agent",
|
||||
icon: "🔍",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatIntervalMs: 15000, maxConcurrentRuns: 2 },
|
||||
permissions: { read: true, write: false },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Prioritize security and edge cases.",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates agents with runtime + instruction fields", async () => {
|
||||
const updatedAgent = { id: "agent-001", name: "reviewer", role: "reviewer", state: "active" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, updatedAgent));
|
||||
|
||||
await updateAgent("agent-001", {
|
||||
runtimeConfig: { heartbeatTimeoutMs: 45000, maxConcurrentRuns: 3 },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Handle migrations cautiously.",
|
||||
pauseReason: "maintenance",
|
||||
reportsTo: undefined,
|
||||
}, "proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001?projectId=proj_123", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
runtimeConfig: { heartbeatTimeoutMs: 45000, maxConcurrentRuns: 3 },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Handle migrations cautiously.",
|
||||
pauseReason: "maintenance",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("startAgentRun", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
|
||||
@@ -1175,6 +1175,9 @@ function ConfigTab({
|
||||
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
||||
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
||||
}
|
||||
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
|
||||
initial.maxConcurrentRuns = String(rc.maxConcurrentRuns);
|
||||
}
|
||||
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
|
||||
initial.messageResponseMode = rc.messageResponseMode;
|
||||
}
|
||||
@@ -1202,7 +1205,7 @@ function ConfigTab({
|
||||
}
|
||||
// Check heartbeat values
|
||||
const rc = agent.runtimeConfig ?? {};
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "messageResponseMode"] as const) {
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
|
||||
const current = heartbeatValues[key]?.trim() ?? "";
|
||||
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
if (current !== persisted) return true;
|
||||
@@ -1251,6 +1254,7 @@ function ConfigTab({
|
||||
for (const [key, config] of Object.entries({
|
||||
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1000 },
|
||||
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5000 },
|
||||
maxConcurrentRuns: { label: "Max Concurrent Runs", min: 1 },
|
||||
})) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
if (!raw) continue;
|
||||
@@ -1289,7 +1293,7 @@ function ConfigTab({
|
||||
|
||||
// Build the runtimeConfig payload — only include non-empty values
|
||||
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs"] as const) {
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
if (!raw) {
|
||||
delete newRuntimeConfig[key];
|
||||
@@ -1420,6 +1424,24 @@ function ConfigTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-maxConcurrentRuns">Max Concurrent Runs</label>
|
||||
<input
|
||||
id="hb-maxConcurrentRuns"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.maxConcurrentRuns && "input--error")}
|
||||
placeholder="1"
|
||||
value={heartbeatValues.maxConcurrentRuns ?? ""}
|
||||
onChange={(e) => handleHeartbeatFieldChange("maxConcurrentRuns", e.target.value)}
|
||||
/>
|
||||
{errors.maxConcurrentRuns ? (
|
||||
<span className="config-error">{errors.maxConcurrentRuns}</span>
|
||||
) : (
|
||||
<span className="config-hint">Maximum simultaneous heartbeat runs for this agent. Leave empty for system default (1).</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-messageResponseMode">Message Response Mode</label>
|
||||
<select
|
||||
|
||||
@@ -165,6 +165,25 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
// Refresh agent list on SSE events (independent from useAgents hook state)
|
||||
useEffect(() => {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const refresh = () => {
|
||||
void loadAgents();
|
||||
};
|
||||
|
||||
es.addEventListener("agent:created", refresh);
|
||||
es.addEventListener("agent:updated", refresh);
|
||||
es.addEventListener("agent:deleted", refresh);
|
||||
es.addEventListener("agent:stateChanged", refresh);
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [projectId, loadAgents]);
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
@@ -253,8 +272,11 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = 60000; // 60 second timeout
|
||||
if (elapsed > timeoutMs) {
|
||||
const runtimeConfig = agent.runtimeConfig as Record<string, unknown> | undefined;
|
||||
const configuredTimeout = typeof runtimeConfig?.heartbeatTimeoutMs === "number"
|
||||
? runtimeConfig.heartbeatTimeoutMs
|
||||
: 60000;
|
||||
if (elapsed > configuredTimeout) {
|
||||
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };
|
||||
|
||||
@@ -78,6 +78,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
const [title, setTitle] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [role, setRole] = useState<AgentCapability>("custom");
|
||||
const [reportsTo, setReportsTo] = useState("");
|
||||
const [instructionsPath, setInstructionsPath] = useState("");
|
||||
const [instructionsText, setInstructionsText] = useState("");
|
||||
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
|
||||
model: "",
|
||||
thinkingLevel: "off",
|
||||
@@ -175,6 +178,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
setTitle("");
|
||||
setIcon("");
|
||||
setRole("custom");
|
||||
setReportsTo("");
|
||||
setInstructionsPath("");
|
||||
setInstructionsText("");
|
||||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
|
||||
setSelectedPresetId(null);
|
||||
setError(null);
|
||||
@@ -196,6 +202,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
role,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(icon.trim() ? { icon: icon.trim() } : {}),
|
||||
...(reportsTo.trim() ? { reportsTo: reportsTo.trim() } : {}),
|
||||
...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}),
|
||||
...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}),
|
||||
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
||||
}, projectId);
|
||||
handleClose();
|
||||
@@ -303,6 +312,39 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-reports-to">Reports To <span className="agent-dialog-optional">(optional agent ID)</span></label>
|
||||
<input
|
||||
id="agent-reports-to"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. agent-1234abcd"
|
||||
value={reportsTo}
|
||||
onChange={e => setReportsTo(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
|
||||
id="agent-instructions-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/reviewer.md"
|
||||
value={instructionsPath}
|
||||
onChange={e => setInstructionsPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-instructions-text">Inline Instructions <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<textarea
|
||||
id="agent-instructions-text"
|
||||
className="input"
|
||||
rows={4}
|
||||
placeholder="Add custom behavior instructions..."
|
||||
value={instructionsText}
|
||||
onChange={e => setInstructionsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* AI-assisted generation */}
|
||||
<div className="agent-dialog-ai-generate">
|
||||
<button
|
||||
@@ -394,6 +436,24 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
<span className="agent-dialog-summary-row-label">Role</span>
|
||||
<span>{selectedRole?.icon} {selectedRole?.label}</span>
|
||||
</div>
|
||||
{reportsTo.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Reports To</span>
|
||||
<span>{reportsTo.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
{instructionsPath.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Instructions File</span>
|
||||
<span>{instructionsPath.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
{instructionsText.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Inline Instructions</span>
|
||||
<span>{instructionsText.trim().length} chars</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Model</span>
|
||||
<span>
|
||||
|
||||
@@ -35,8 +35,7 @@ export function useAgents(projectId?: string) {
|
||||
|
||||
// SSE subscription for agent events
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
const query = `?projectId=${encodeURIComponent(projectId)}`;
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const refresh = () => {
|
||||
|
||||
@@ -262,7 +262,6 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
|
||||
describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
let mockStartRun: ReturnType<typeof vi.fn>;
|
||||
let mockExecuteHeartbeat: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -271,14 +270,12 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
mockGetActiveHeartbeatRun.mockResolvedValue(null);
|
||||
|
||||
mockStartRun = vi.fn();
|
||||
mockExecuteHeartbeat = vi.fn();
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any, {
|
||||
heartbeatMonitor: {
|
||||
startRun: mockStartRun,
|
||||
executeHeartbeat: mockExecuteHeartbeat,
|
||||
},
|
||||
});
|
||||
@@ -289,9 +286,8 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
it("delegates to heartbeatMonitor.startRun when available", async () => {
|
||||
it("delegates to heartbeatMonitor.executeHeartbeat when available", async () => {
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand", triggerDetail: "Triggered from dashboard" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue({ ...mockRun, status: "completed" });
|
||||
|
||||
const response = await request(
|
||||
@@ -303,26 +299,20 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
},
|
||||
});
|
||||
// executeHeartbeat should be called fire-and-forget
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
taskId: undefined,
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes custom source and triggerDetail to heartbeatMonitor", async () => {
|
||||
const mockRun = createMockRun();
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
await request(
|
||||
@@ -333,9 +323,11 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
taskId: undefined,
|
||||
contextSnapshot: {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
@@ -349,7 +341,6 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
mockRecordHeartbeat.mockResolvedValue(mockEvent);
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
const response = await request(
|
||||
@@ -361,8 +352,15 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartRun).toHaveBeenCalled();
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalled();
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
},
|
||||
});
|
||||
// Response should include both event and run
|
||||
expect((response.body as any).event).toBeDefined();
|
||||
expect((response.body as any).run).toBeDefined();
|
||||
|
||||
@@ -7876,6 +7876,131 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent create/update routes", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agents-fields-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Initial Agent",
|
||||
role: "executor",
|
||||
});
|
||||
agentId = agent.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildAgentApp() {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("POST /api/agents accepts all AgentCreateInput fields", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
"/api/agents",
|
||||
JSON.stringify({
|
||||
name: "Full Agent",
|
||||
role: "reviewer",
|
||||
metadata: { team: "qa" },
|
||||
title: "QA Reviewer",
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
name: "Full Agent",
|
||||
role: "reviewer",
|
||||
metadata: { team: "qa" },
|
||||
title: "QA Reviewer",
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCH /api/agents/:id accepts all AgentUpdateInput fields", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"PATCH",
|
||||
`/api/agents/${agentId}`,
|
||||
JSON.stringify({
|
||||
name: "Updated Agent",
|
||||
role: "engineer",
|
||||
metadata: { area: "infra" },
|
||||
title: "Infra Engineer",
|
||||
icon: "⚙️",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
totalInputTokens: 42,
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: agentId,
|
||||
name: "Updated Agent",
|
||||
role: "engineer",
|
||||
metadata: { area: "infra" },
|
||||
title: "Infra Engineer",
|
||||
icon: "⚙️",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
totalInputTokens: 42,
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/state`,
|
||||
JSON.stringify({ state: "terminated" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid state transition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
|
||||
@@ -6893,14 +6893,69 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
function validateAgentInstructionsPayload(
|
||||
res: Response,
|
||||
instructionsPath: unknown,
|
||||
instructionsText: unknown,
|
||||
): boolean {
|
||||
if (instructionsPath !== undefined && instructionsPath !== null && instructionsPath !== "") {
|
||||
if (typeof instructionsPath !== "string") {
|
||||
res.status(400).json({ error: "instructionsPath must be a string" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsPath.length > 500) {
|
||||
res.status(400).json({ error: "instructionsPath must be at most 500 characters" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsPath.includes("..")) {
|
||||
res.status(400).json({ error: "instructionsPath must not contain parent directory traversal (..)" });
|
||||
return false;
|
||||
}
|
||||
const isAbsoluteUnix = instructionsPath.startsWith("/");
|
||||
const isAbsoluteWindows = /^[A-Za-z]:[\\/]/.test(instructionsPath);
|
||||
if (isAbsoluteUnix || isAbsoluteWindows) {
|
||||
res.status(400).json({ error: "instructionsPath must be a project-relative path" });
|
||||
return false;
|
||||
}
|
||||
if (!instructionsPath.endsWith(".md")) {
|
||||
res.status(400).json({ error: "instructionsPath must end in .md" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (instructionsText !== undefined && instructionsText !== null && instructionsText !== "") {
|
||||
if (typeof instructionsText !== "string") {
|
||||
res.status(400).json({ error: "instructionsText must be a string" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsText.length > 50000) {
|
||||
res.status(400).json({ error: "instructionsText must be at most 50,000 characters" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/agents
|
||||
* Create a new agent.
|
||||
* Body: { name: string, role: string, metadata?: object }
|
||||
*/
|
||||
router.post("/agents", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata } = req.body;
|
||||
const {
|
||||
name,
|
||||
role,
|
||||
metadata,
|
||||
title,
|
||||
icon,
|
||||
reportsTo,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath,
|
||||
instructionsText,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
@@ -6909,16 +6964,58 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
res.status(400).json({ error: "role is required" });
|
||||
return;
|
||||
}
|
||||
if (metadata !== undefined && (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))) {
|
||||
res.status(400).json({ error: "metadata must be an object" });
|
||||
return;
|
||||
}
|
||||
if (title !== undefined && title !== null && typeof title !== "string") {
|
||||
res.status(400).json({ error: "title must be a string" });
|
||||
return;
|
||||
}
|
||||
if (icon !== undefined && icon !== null && typeof icon !== "string") {
|
||||
res.status(400).json({ error: "icon must be a string" });
|
||||
return;
|
||||
}
|
||||
if (reportsTo !== undefined && reportsTo !== null && typeof reportsTo !== "string") {
|
||||
res.status(400).json({ error: "reportsTo must be a string" });
|
||||
return;
|
||||
}
|
||||
if (runtimeConfig !== undefined && (typeof runtimeConfig !== "object" || runtimeConfig === null || Array.isArray(runtimeConfig))) {
|
||||
res.status(400).json({ error: "runtimeConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
if (permissions !== undefined && (typeof permissions !== "object" || permissions === null || Array.isArray(permissions))) {
|
||||
res.status(400).json({ error: "permissions must be an object" });
|
||||
return;
|
||||
}
|
||||
if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.createAgent({ name, role: role as import("@fusion/core").AgentCapability, metadata });
|
||||
const agent = await agentStore.createAgent({
|
||||
name,
|
||||
role: role as import("@fusion/core").AgentCapability,
|
||||
metadata,
|
||||
title: title ?? undefined,
|
||||
icon: icon ?? undefined,
|
||||
reportsTo: reportsTo ?? undefined,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
});
|
||||
res.status(201).json(agent);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
if (err.message?.includes("required") || err.message?.includes("cannot be empty")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7079,18 +7176,119 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
router.patch("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata, runtimeConfig } = req.body;
|
||||
const body = req.body ?? {};
|
||||
const updates: import("@fusion/core").AgentUpdateInput = {};
|
||||
|
||||
if ("name" in body) {
|
||||
if (body.name !== null && typeof body.name !== "string") {
|
||||
res.status(400).json({ error: "name must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.name = body.name ?? undefined;
|
||||
}
|
||||
|
||||
if ("role" in body) {
|
||||
if (body.role !== null && typeof body.role !== "string") {
|
||||
res.status(400).json({ error: "role must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.role = body.role ?? undefined;
|
||||
}
|
||||
|
||||
if ("metadata" in body) {
|
||||
if (body.metadata !== null && (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
||||
res.status(400).json({ error: "metadata must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.metadata = body.metadata ?? undefined;
|
||||
}
|
||||
|
||||
if ("title" in body) {
|
||||
if (body.title !== null && typeof body.title !== "string") {
|
||||
res.status(400).json({ error: "title must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.title = body.title ?? undefined;
|
||||
}
|
||||
|
||||
if ("icon" in body) {
|
||||
if (body.icon !== null && typeof body.icon !== "string") {
|
||||
res.status(400).json({ error: "icon must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.icon = body.icon ?? undefined;
|
||||
}
|
||||
|
||||
if ("reportsTo" in body) {
|
||||
if (body.reportsTo !== null && typeof body.reportsTo !== "string") {
|
||||
res.status(400).json({ error: "reportsTo must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.reportsTo = body.reportsTo ?? undefined;
|
||||
}
|
||||
|
||||
if ("pauseReason" in body) {
|
||||
if (body.pauseReason !== null && typeof body.pauseReason !== "string") {
|
||||
res.status(400).json({ error: "pauseReason must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.pauseReason = body.pauseReason ?? undefined;
|
||||
}
|
||||
|
||||
if ("runtimeConfig" in body) {
|
||||
if (body.runtimeConfig !== null && (typeof body.runtimeConfig !== "object" || Array.isArray(body.runtimeConfig))) {
|
||||
res.status(400).json({ error: "runtimeConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.runtimeConfig = body.runtimeConfig ?? undefined;
|
||||
}
|
||||
|
||||
if ("permissions" in body) {
|
||||
if (body.permissions !== null && (typeof body.permissions !== "object" || Array.isArray(body.permissions))) {
|
||||
res.status(400).json({ error: "permissions must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.permissions = body.permissions ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalInputTokens" in body) {
|
||||
if (body.totalInputTokens !== null && typeof body.totalInputTokens !== "number") {
|
||||
res.status(400).json({ error: "totalInputTokens must be a number" });
|
||||
return;
|
||||
}
|
||||
updates.totalInputTokens = body.totalInputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalOutputTokens" in body) {
|
||||
if (body.totalOutputTokens !== null && typeof body.totalOutputTokens !== "number") {
|
||||
res.status(400).json({ error: "totalOutputTokens must be a number" });
|
||||
return;
|
||||
}
|
||||
updates.totalOutputTokens = body.totalOutputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if (!validateAgentInstructionsPayload(res, body.instructionsPath, body.instructionsText)) {
|
||||
return;
|
||||
}
|
||||
if ("instructionsPath" in body) {
|
||||
updates.instructionsPath = body.instructionsPath ?? undefined;
|
||||
}
|
||||
if ("instructionsText" in body) {
|
||||
updates.instructionsText = body.instructionsText ?? undefined;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgent(req.params.id, { name, role, metadata, runtimeConfig });
|
||||
const agent = await agentStore.updateAgent(req.params.id, updates);
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("cannot be empty")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -7104,38 +7302,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
router.patch("/agents/:id/instructions", async (req, res) => {
|
||||
try {
|
||||
const { instructionsPath, instructionsText } = req.body;
|
||||
|
||||
// Validate instructionsPath if provided
|
||||
if (instructionsPath !== undefined && instructionsPath !== "") {
|
||||
if (typeof instructionsPath !== "string") {
|
||||
res.status(400).json({ error: "instructionsPath must be a string" });
|
||||
return;
|
||||
}
|
||||
if (instructionsPath.length > 500) {
|
||||
res.status(400).json({ error: "instructionsPath must be at most 500 characters" });
|
||||
return;
|
||||
}
|
||||
if (instructionsPath.includes("..")) {
|
||||
res.status(400).json({ error: "instructionsPath must not contain parent directory traversal (..)" });
|
||||
return;
|
||||
}
|
||||
if (!instructionsPath.endsWith(".md")) {
|
||||
res.status(400).json({ error: "instructionsPath must end in .md" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate instructionsText if provided
|
||||
if (instructionsText !== undefined && instructionsText !== "") {
|
||||
if (typeof instructionsText !== "string") {
|
||||
res.status(400).json({ error: "instructionsText must be a string" });
|
||||
return;
|
||||
}
|
||||
if (instructionsText.length > 50000) {
|
||||
res.status(400).json({ error: "instructionsText must be at most 50,000 characters" });
|
||||
return;
|
||||
}
|
||||
const { instructionsPath, instructionsText } = req.body ?? {};
|
||||
if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
@@ -7143,7 +7312,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgent(req.params.id, { instructionsPath, instructionsText });
|
||||
const agent = await agentStore.updateAgent(req.params.id, {
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
});
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
@@ -7177,7 +7349,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("Invalid state transition") || err.message?.includes("Cannot transition from terminated")) {
|
||||
} else if (/invalid state transition/i.test(err.message ?? "")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -7316,7 +7488,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
* Body: { status?: "ok"|"missed"|"recovered", triggerExecution?: boolean }
|
||||
*
|
||||
* When triggerExecution is true AND HeartbeatMonitor is available,
|
||||
* also starts a heartbeat run after recording the heartbeat event.
|
||||
* also executes a heartbeat run after recording the heartbeat event.
|
||||
*/
|
||||
router.post("/agents/:id/heartbeat", async (req, res) => {
|
||||
try {
|
||||
@@ -7332,18 +7504,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
// Optionally trigger execution
|
||||
let run: import("@fusion/core").AgentHeartbeatRun | undefined;
|
||||
if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor) {
|
||||
run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
});
|
||||
|
||||
// Fire-and-forget execution
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
run = await heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7407,11 +7575,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
* Manually start a heartbeat run for an agent.
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string, taskId?: string }
|
||||
*
|
||||
* When HeartbeatMonitor is available, delegates to startRun() which enriches
|
||||
* the run with execution context, transitions the agent to "running", and
|
||||
* fires the onRunStarted event. The route returns the run immediately with
|
||||
* "active" status while execution continues in the background via
|
||||
* executeHeartbeat() fire-and-forget.
|
||||
* When HeartbeatMonitor is available, delegates to executeHeartbeat() with
|
||||
* a structured wake context snapshot. This ensures a single authoritative run
|
||||
* record is created and fully completed without duplicate startRun calls.
|
||||
*
|
||||
* Returns 409 Conflict if the agent already has an active run.
|
||||
*/
|
||||
@@ -7443,21 +7609,13 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to HeartbeatMonitor for enriched run creation
|
||||
const run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
// Fire-and-forget execution in the background
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
// Execute heartbeat end-to-end (single run record, no duplicate startRun call)
|
||||
const run = await heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
taskId,
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
res.status(201).json(run);
|
||||
@@ -7479,7 +7637,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
// Enrich with invocation source, trigger detail, and context snapshot
|
||||
(run as any).invocationSource = invocationSource;
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
(run as any).triggerDetail = trigger;
|
||||
(run as any).contextSnapshot = contextSnapshot;
|
||||
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface ServerOptions {
|
||||
/** Optional HeartbeatMonitor for triggering agent execution runs */
|
||||
heartbeatMonitor?: {
|
||||
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user