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:
gsxdsm
2026-04-05 23:36:42 -07:00
parent 3cd8f8ed54
commit 63388551d1
4 changed files with 128 additions and 1 deletions

View File

@@ -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;

View File

@@ -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));

View File

@@ -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");

View File

@@ -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", () => {