feat(FN-2501): merge fusion/fn-2501

This commit is contained in:
gsxdsm
2026-04-25 14:24:35 -07:00
parent 633644a665
commit 1a8058f334
10 changed files with 231 additions and 40 deletions

View File

@@ -118,6 +118,26 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
vi.restoreAllMocks();
});
describe("POST /api/agents/:id/state", () => {
it("pausing with no active run remains successful", async () => {
mockGetActiveHeartbeatRun.mockResolvedValue(null);
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "paused" });
const response = await request(
app,
"POST",
"/api/agents/agent-001/state",
JSON.stringify({ state: "paused" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: "agent-001", state: "paused" });
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused");
expect(mockGetActiveHeartbeatRun).not.toHaveBeenCalled();
});
});
describe("POST /api/agents/:id/runs", () => {
it("returns 201 with run record (fallback behavior without HeartbeatMonitor)", async () => {
const mockRun = createMockRun();
@@ -403,6 +423,56 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
vi.restoreAllMocks();
});
describe("POST /api/agents/:id/state", () => {
it("pauses by stopping active run before updating state", async () => {
mockGetActiveHeartbeatRun.mockResolvedValue(createMockRun({ id: "run-pause-1" }));
mockStopRun.mockResolvedValue(undefined);
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "paused" });
const response = await request(
app,
"POST",
"/api/agents/agent-001/state",
JSON.stringify({ state: "paused" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: "agent-001", state: "paused" });
expect(mockStopRun).toHaveBeenCalledWith("agent-001");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused");
expect(mockStopRun.mock.invocationCallOrder[0]).toBeLessThan(mockUpdateAgentState.mock.invocationCallOrder[0]);
});
it("resuming to active triggers immediate on-demand heartbeat exactly once", async () => {
mockGetAgent.mockResolvedValue({ id: "agent-001", state: "paused" });
mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" });
mockExecuteHeartbeat.mockResolvedValue(createMockRun({ id: "run-resume-1", status: "completed" }));
const response = await request(
app,
"POST",
"/api/agents/agent-001/state",
JSON.stringify({ state: "active" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: "agent-001", state: "active" });
expect(mockExecuteHeartbeat).toHaveBeenCalledTimes(1);
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
agentId: "agent-001",
source: "on_demand",
triggerDetail: "Triggered from state resume",
contextSnapshot: {
wakeReason: "on_demand",
triggerDetail: "Triggered from state resume",
triggerSource: "state-resume",
},
});
});
});
describe("POST /api/agents/:id/runs", () => {
it("delegates to heartbeatMonitor.executeHeartbeat when available", async () => {
const mockRun = createMockRun({ invocationSource: "on_demand", triggerDetail: "Triggered from dashboard" });

View File

@@ -15740,6 +15740,27 @@ describe("Agent create/update routes", () => {
expect(res.body.error).toContain("soul must be at most 10,000 characters");
});
it("POST /api/agents/:id/state pauses successfully without heartbeat monitor wiring", async () => {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.updateAgentState(agentId, "active");
const res = await REQUEST(
buildAgentApp(),
"POST",
`/api/agents/${agentId}/state`,
JSON.stringify({ state: "paused" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: agentId, state: "paused" });
const updatedAgent = await agentStore.getAgent(agentId);
expect(updatedAgent?.state).toBe("paused");
});
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
const res = await REQUEST(
buildAgentApp(),

View File

@@ -13419,13 +13419,48 @@ async function persistImportedSkills(
throw badRequest("state is required");
}
const nextState = state as import("@fusion/core").AgentState;
const agentId = req.params.id;
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.updateAgentState(req.params.id, state as import("@fusion/core").AgentState);
res.json(agent);
const currentAgent = await agentStore.getAgent(agentId);
if (!currentAgent) {
throw notFound("Agent not found");
}
const projectHeartbeatMonitor = hasHeartbeatExecutor
&& heartbeatMonitor
&& isHeartbeatMonitorForProject(scopedStore)
? heartbeatMonitor
: null;
if (nextState === "paused" && projectHeartbeatMonitor) {
const activeRun = await agentStore.getActiveHeartbeatRun(agentId);
if (activeRun) {
await projectHeartbeatMonitor.stopRun(agentId);
}
}
const updatedAgent = await agentStore.updateAgentState(agentId, nextState);
if (nextState === "active" && projectHeartbeatMonitor) {
await projectHeartbeatMonitor.executeHeartbeat({
agentId,
source: "on_demand",
triggerDetail: "Triggered from state resume",
contextSnapshot: {
wakeReason: "on_demand",
triggerDetail: "Triggered from state resume",
triggerSource: "state-resume",
},
});
}
res.json(updatedAgent);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;