feat(FN-2501): merge fusion/fn-2501
This commit is contained in:
@@ -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" });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user