feat(FN-997): add startAgentRun API and auto-start heartbeat on agent activation
- Add startAgentRun() API function to start a heartbeat run for a given agent - Auto-trigger startAgentRun when agent status transitions to active in AgentsView - Add API tests for startAgentRun endpoint - Add component tests for auto-start behavior on activation
This commit is contained in:
@@ -906,6 +906,7 @@ describe("refineTask", () => {
|
||||
// --- Git Management API tests ---
|
||||
|
||||
import {
|
||||
startAgentRun,
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
@@ -921,6 +922,54 @@ import {
|
||||
pushBranch,
|
||||
} from "./api";
|
||||
|
||||
describe("startAgentRun", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("sends POST to start a run for an agent", async () => {
|
||||
const mockRun = {
|
||||
id: "run-001",
|
||||
agentId: "agent-001",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockRun, 201));
|
||||
|
||||
const result = await startAgentRun("agent-001");
|
||||
|
||||
expect(result.id).toBe("run-001");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/runs", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: "manual", triggerDetail: "Agent activated via dashboard" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes projectId as query param", async () => {
|
||||
const mockRun = { id: "run-001", agentId: "agent-001", startedAt: "", endedAt: null, status: "active" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockRun, 201));
|
||||
|
||||
await startAgentRun("agent-001", "proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/agents/agent-001/runs?projectId=proj_123",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on 404 when agent not found", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Agent agent-999 not found" }, 404),
|
||||
);
|
||||
|
||||
await expect(startAgentRun("agent-999")).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Git Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
|
||||
@@ -1815,6 +1815,14 @@ export function fetchAgentRunDetail(agentId: string, runId: string, projectId?:
|
||||
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, projectId));
|
||||
}
|
||||
|
||||
/** Manually start a heartbeat run for an agent */
|
||||
export function startAgentRun(agentId: string, projectId?: string): Promise<AgentHeartbeatRun> {
|
||||
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: "manual", triggerDetail: "Agent activated via dashboard" }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch aggregate agent stats */
|
||||
export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, Filter } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
@@ -75,6 +75,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
|
||||
// When activating an agent, also start a heartbeat run so it shows activity
|
||||
if (newState === "active") {
|
||||
try {
|
||||
await startAgentRun(agentId, projectId);
|
||||
} catch (runErr: any) {
|
||||
addToast(`Agent activated, but failed to start run: ${runErr.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update state: ${err.message}`, "error");
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock("../../api", () => ({
|
||||
updateAgent: vi.fn(),
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
}));
|
||||
|
||||
@@ -19,6 +20,7 @@ const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
|
||||
const mockCreateAgent = vi.mocked(apiModule.createAgent);
|
||||
const mockUpdateAgentState = vi.mocked(apiModule.updateAgentState);
|
||||
const mockDeleteAgent = vi.mocked(apiModule.deleteAgent);
|
||||
const mockStartAgentRun = vi.mocked(apiModule.startAgentRun);
|
||||
const mockFetchAgentStats = vi.mocked((apiModule as any).fetchAgentStats);
|
||||
|
||||
describe("AgentsView", () => {
|
||||
@@ -73,6 +75,13 @@ describe("AgentsView", () => {
|
||||
mockCreateAgent.mockResolvedValue(mockAgents[0]);
|
||||
mockUpdateAgentState.mockResolvedValue({ ...mockAgents[0], state: "active" });
|
||||
mockDeleteAgent.mockResolvedValue(undefined);
|
||||
mockStartAgentRun.mockResolvedValue({
|
||||
id: "run-001",
|
||||
agentId: "agent-001",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
@@ -375,6 +384,7 @@ describe("AgentsView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
|
||||
expect(mockStartAgentRun).toHaveBeenCalledWith("agent-001", undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
@@ -421,6 +431,7 @@ describe("AgentsView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active", undefined);
|
||||
expect(mockStartAgentRun).toHaveBeenCalledWith("agent-003", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -442,6 +453,55 @@ describe("AgentsView", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when startAgentRun fails but still updates state", async () => {
|
||||
mockStartAgentRun.mockRejectedValue(new Error("Run failed"));
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Activate")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("Activate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
|
||||
expect(mockStartAgentRun).toHaveBeenCalledWith("agent-001", undefined);
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed to start run"),
|
||||
"error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not start run when pausing agent", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
expect(agentCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find the active agent card
|
||||
const agentCards = document.querySelectorAll(".agent-card");
|
||||
let activeCard: Element | null = null;
|
||||
agentCards.forEach(card => {
|
||||
if (card.textContent?.includes("agent-002")) {
|
||||
activeCard = card;
|
||||
}
|
||||
});
|
||||
|
||||
const pauseButton = activeCard?.querySelector('[title="Pause"]') as HTMLElement;
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined);
|
||||
});
|
||||
|
||||
// startAgentRun should NOT be called when pausing
|
||||
expect(mockStartAgentRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete agent", () => {
|
||||
|
||||
Reference in New Issue
Block a user