feat(FN-1059): add manual heartbeat run trigger with execution details in agent UI

- Wire heartbeatMonitor into ServerOptions and pass through to API routes
- Add POST /api/agents/:id/runs route to trigger heartbeat execution with optional source/triggerDetail
- Update API client startAgentRun to accept optional source and triggerDetail parameters
- Add Run Heartbeat button to AgentsView and enhance AgentDetailView RunsTab with execution details and polling
- Add route handler tests for agent runs and UI static analysis tests
- Support run history display with status, duration, trigger type, and result details
This commit is contained in:
gsxdsm
2026-04-07 14:38:12 -07:00
parent d8f4a63e1f
commit f34c86345c
8 changed files with 899 additions and 55 deletions

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as fs from "fs";
import * as path from "path";
// ── Static analysis tests for agent run UI ────────────────────────────
// These tests verify that the agent run UI components are properly wired
// by analyzing the source files for the expected patterns.
const agentsViewPath = path.join(__dirname, "../components/AgentsView.tsx");
const agentDetailViewPath = path.join(__dirname, "../components/AgentDetailView.tsx");
const apiPath = path.join(__dirname, "../api.ts");
const agentsViewContent = fs.readFileSync(agentsViewPath, "utf-8");
const agentDetailViewContent = fs.readFileSync(agentDetailViewPath, "utf-8");
const apiContent = fs.readFileSync(apiPath, "utf-8");
describe("Agent runs UI — static analysis", () => {
describe("startAgentRun API function", () => {
it("accepts optional source and triggerDetail parameters", () => {
expect(apiContent).toMatch(/startAgentRun\s*\(\s*agentId.*projectId\?/s);
expect(apiContent).toMatch(/options\?\.\s*source/);
expect(apiContent).toMatch(/options\?\.\s*triggerDetail/);
});
it("exports HeartbeatInvocationSource type", () => {
expect(apiContent).toMatch(/export type.*HeartbeatInvocationSource/);
});
});
describe("AgentsView Run Heartbeat button", () => {
it("has handleRunHeartbeat function", () => {
expect(agentsViewContent).toContain("handleRunHeartbeat");
});
it("calls startAgentRun with on_demand source", () => {
expect(agentsViewContent).toMatch(/startAgentRun.*on_demand/);
expect(agentsViewContent).toMatch(/startAgentRun.*Triggered from dashboard/);
});
it("shows Run Heartbeat button for active agents with taskId", () => {
// The button should appear in the active state block
// and should be conditioned on agent.taskId
const activeBlock = agentsViewContent.match(/agent\.state === "active"[\s\S]*?agent\.state === "paused"/)?.[0] ?? "";
expect(activeBlock).toContain("handleRunHeartbeat");
expect(activeBlock).toContain("agent.taskId");
});
it("shows disabled button for running agents", () => {
const runningBlock = agentsViewContent.match(/agent\.state === "running"[\s\S]*?agent\.state === "error"/)?.[0] ?? "";
expect(runningBlock).toContain("disabled");
});
it("uses Activity icon for the Run Heartbeat button", () => {
expect(agentsViewContent).toContain("handleRunHeartbeat");
// Activity icon is imported and used in run heartbeat buttons
expect(agentsViewContent).toMatch(/from.*lucide-react/);
expect(agentsViewContent).toContain("Activity");
});
});
describe("AgentDetailView RunsTab", () => {
it("loads runs via fetchAgentRuns API", () => {
expect(agentDetailViewContent).toMatch(/fetchAgentRuns/);
});
it("loads run detail via fetchAgentRunDetail API", () => {
expect(agentDetailViewContent).toMatch(/fetchAgentRunDetail/);
});
it("has startAgentRun import for Run Heartbeat button", () => {
expect(agentDetailViewContent).toMatch(/import.*startAgentRun.*from.*api/);
});
it("has Run Heartbeat button in runs tab", () => {
expect(agentDetailViewContent).toMatch(/Run Heartbeat/);
expect(agentDetailViewContent).toMatch(/handleRunHeartbeat/);
});
it("displays stdoutExcerpt in pre block", () => {
expect(agentDetailViewContent).toContain("stdoutExcerpt");
});
it("displays stderrExcerpt", () => {
expect(agentDetailViewContent).toContain("stderrExcerpt");
});
it("displays token usage (usageJson)", () => {
expect(agentDetailViewContent).toContain("usageJson");
expect(agentDetailViewContent).toMatch(/inputTokens|outputTokens|cachedTokens/);
});
it("displays resultJson", () => {
expect(agentDetailViewContent).toContain("resultJson");
});
it("displays contextSnapshot", () => {
expect(agentDetailViewContent).toContain("contextSnapshot");
});
it("has polling for active runs", () => {
expect(agentDetailViewContent).toContain("setInterval");
expect(agentDetailViewContent).toContain("5000");
});
it("has empty state for no runs", () => {
expect(agentDetailViewContent).toContain("No runs yet");
});
it("has empty state for no output captured", () => {
expect(agentDetailViewContent).toContain("No output captured");
});
it("has invocation source badge", () => {
expect(agentDetailViewContent).toContain("invocationSource");
});
it("has trigger detail display", () => {
expect(agentDetailViewContent).toContain("triggerDetail");
});
it("has no-runs empty state", () => {
expect(agentDetailViewContent).toContain("No runs yet");
});
});
});

View File

@@ -1747,8 +1747,8 @@ export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): P
// ── Agent API ────────────────────────────────────────────────────────────
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats } from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats };
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource } from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource };
function withProjectId(path: string, projectId?: string): string {
if (!projectId) return path;
@@ -1846,10 +1846,16 @@ export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: st
}
/** Manually start a heartbeat run for an agent */
export function startAgentRun(agentId: string, projectId?: string): Promise<AgentHeartbeatRun> {
export function startAgentRun(
agentId: string,
projectId?: string,
options?: { source?: HeartbeatInvocationSource; triggerDetail?: string },
): Promise<AgentHeartbeatRun> {
const source = options?.source ?? "manual";
const triggerDetail = options?.triggerDetail ?? "Agent activated via dashboard";
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {
method: "POST",
body: JSON.stringify({ source: "manual", triggerDetail: "Agent activated via dashboard" }),
body: JSON.stringify({ source, triggerDetail }),
});
}

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -237,8 +237,6 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const stateStyle = STATE_COLORS[agent.state];
const health = getHealthStatus();
const runs = (agent as any).completedRuns || [];
const activeRun = (agent as any).activeRun;
return (
<div className="agent-detail-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
@@ -376,11 +374,11 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
{activeTab === "runs" && (
<RunsTab
runs={runs}
activeRun={activeRun}
addToast={addToast}
agentId={agent.id}
projectId={projectId}
agentState={agent.state}
agentName={agent.name}
/>
)}
@@ -675,48 +673,125 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
// ── Runs Tab ───────────────────────────────────────────────────────────────
function RunsTab({
runs,
activeRun,
addToast,
agentId,
projectId,
agentState,
agentName,
}: {
runs: AgentHeartbeatRun[];
activeRun?: AgentHeartbeatRun;
addToast: (msg: string, type?: "success" | "error") => void;
agentId: string;
projectId?: string;
agentState?: AgentState;
agentName?: string;
}) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
// Load runs on mount
const loadRuns = useCallback(async () => {
try {
const data = await fetchAgentRuns(agentId, 50, projectId);
setRuns(data);
} catch (err: any) {
addToast(`Failed to load runs: ${err.message}`, "error");
} finally {
setIsLoadingRuns(false);
}
}, [agentId, projectId, addToast]);
useEffect(() => {
void loadRuns();
}, [loadRuns]);
// Poll for active runs
const hasActiveRun = runs.some(r => r.status === "active");
useEffect(() => {
if (!hasActiveRun) return;
const interval = setInterval(() => {
void loadRuns();
}, 5000);
return () => clearInterval(interval);
}, [hasActiveRun, loadRuns]);
// Load run detail when a run is selected
const handleRunClick = useCallback(async (runId: string) => {
if (selectedRunId === runId) {
setSelectedRunId(null);
setRunLogs([]);
setDetailRun(null);
return;
}
setSelectedRunId(runId);
setIsLoadingLogs(true);
setIsLoadingDetail(true);
setRunLogs([]);
setDetailRun(null);
try {
const logs = await fetchAgentRunLogs(agentId, runId, projectId);
const [logs, detail] = await Promise.all([
fetchAgentRunLogs(agentId, runId, projectId),
fetchAgentRunDetail(agentId, runId, projectId),
]);
setRunLogs(logs);
setDetailRun(detail);
} catch (err: any) {
addToast(`Failed to load run logs: ${err.message}`, "error");
addToast(`Failed to load run details: ${err.message}`, "error");
setRunLogs([]);
setDetailRun(null);
} finally {
setIsLoadingLogs(false);
setIsLoadingDetail(false);
}
}, [selectedRunId, agentId, projectId, addToast]);
if (runs.length === 0 && !activeRun) {
const handleRunHeartbeat = async () => {
try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agentName ?? agentId}`, "success");
setIsLoadingRuns(true);
void loadRuns();
} catch (err: any) {
addToast(`Failed to start heartbeat run: ${err.message}`, "error");
}
};
const canRunHeartbeat = agentState === "active" || agentState === "idle";
if (isLoadingRuns && runs.length === 0) {
return (
<div className="runs-empty">
<Activity size={48} opacity={0.3} />
<p>No runs yet</p>
<p className="text-muted">Heartbeat runs will appear here</p>
<div className="runs-tab">
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "24px", justifyContent: "center" }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading runs...</span>
</div>
</div>
);
}
if (runs.length === 0) {
return (
<div className="runs-tab">
{canRunHeartbeat && (
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)" }}>
<button
className="btn btn--sm btn--primary"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Heartbeat
</button>
</div>
)}
<div className="runs-empty">
<Activity size={48} opacity={0.3} />
<p>No runs yet</p>
<p className="text-muted">Heartbeat runs will appear here</p>
</div>
</div>
);
}
@@ -725,6 +800,20 @@ function RunsTab({
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
const activeRuns = sortedRuns.filter(r => r.status === "active");
const completedRuns = sortedRuns.filter(r => r.status !== "active");
const renderUsage = (usage: { inputTokens: number; outputTokens: number; cachedTokens: number } | undefined) => {
if (!usage) return null;
return (
<div style={{ fontSize: "12px", color: "var(--text-secondary)", display: "flex", gap: "12px", flexWrap: "wrap" }}>
<span>Input: {usage.inputTokens.toLocaleString()}</span>
<span>Output: {usage.outputTokens.toLocaleString()}</span>
{usage.cachedTokens > 0 && <span>Cached: {usage.cachedTokens.toLocaleString()}</span>}
</div>
);
};
const renderRunCard = (run: AgentHeartbeatRun, index: number, isActive: boolean) => {
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
const StatusIcon = statusInfo.icon;
@@ -762,15 +851,28 @@ function RunsTab({
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
)}
</div>
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{run.invocationSource && (
<span className="badge" style={{ fontSize: "10px", padding: "1px 6px" }}>
{run.invocationSource}
</span>
)}
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
</span>
</div>
</div>
<div className="run-details">
<span>Started {relativeTime(run.startedAt)}</span>
<span></span>
<span>{duration}</span>
{run.triggerDetail && (
<>
<span></span>
<span className="text-muted">{run.triggerDetail}</span>
</>
)}
</div>
</div>
{isSelected && (
@@ -783,18 +885,137 @@ function RunsTab({
borderTop: "1px solid var(--border-color)",
}}
>
{isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "12px 0" }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
{/* Execution Details */}
{isLoadingDetail ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading details...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "12px 0", fontStyle: "italic" }}>
No logs available for this run
) : detailRun && (
<div style={{ marginBottom: "12px" }}>
{/* Token Usage */}
{detailRun.usageJson && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Token Usage
</div>
{renderUsage(detailRun.usageJson)}
</div>
)}
{/* Output */}
{detailRun.stdoutExcerpt && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Output
</div>
<pre style={{
background: "var(--bg-tertiary, #161b22)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{detailRun.stdoutExcerpt.length > 2000
? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)`
: detailRun.stdoutExcerpt}
</pre>
</div>
)}
{/* Errors */}
{detailRun.stderrExcerpt && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--color-error, #f85149)", marginBottom: "4px" }}>
Errors
</div>
<pre style={{
background: "rgba(248, 81, 73, 0.1)",
color: "var(--color-error, #f85149)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{detailRun.stderrExcerpt}
</pre>
</div>
)}
{/* Result */}
{detailRun.resultJson && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Result
</div>
<pre style={{
background: "var(--bg-tertiary, #161b22)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{JSON.stringify(detailRun.resultJson, null, 2)}
</pre>
</div>
)}
{/* Context */}
{detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Context
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", fontSize: "12px" }}>
{Object.entries(detailRun.contextSnapshot).map(([key, value]) => (
<span key={key}>
<span className="text-muted">{key}:</span>{" "}
<span>{String(value)}</span>
</span>
))}
</div>
</div>
)}
{/* No output state */}
{!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic", fontSize: "12px" }}>
No output captured
</div>
)}
</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}
{/* Run Logs */}
<div style={{ borderTop: "1px solid var(--border-color)", paddingTop: "8px", marginTop: "4px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Agent Logs
</div>
{isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic" }}>
No logs available for this run
</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}
</div>
</div>
)}
</div>
@@ -803,8 +1024,23 @@ function RunsTab({
return (
<div className="runs-tab">
{activeRun && renderRunCard(activeRun, 0, true)}
{sortedRuns.map((run, i) => renderRunCard(run, activeRun ? i + 1 : i, false))}
{canRunHeartbeat && (
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
{runs.length} run{runs.length !== 1 ? "s" : ""}
{hasActiveRun && <span className="run-live-indicator" style={{ marginLeft: "8px" }}><span className="live-dot" />Live</span>}
</span>
<button
className="btn btn--sm btn--primary"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Heartbeat
</button>
</div>
)}
{activeRuns.map((run, i) => renderRunCard(run, i, true))}
{completedRuns.map((run, i) => renderRunCard(run, activeRuns.length + i, false))}
</div>
);
}

View File

@@ -222,6 +222,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
}
};
const handleRunHeartbeat = async (agentId: string, agentName: string) => {
try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agentName}`, "success");
void loadAgents();
} catch (err: any) {
addToast(`Failed to start heartbeat run: ${err.message}`, "error");
}
};
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
@@ -445,6 +455,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "active" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
title="Run Heartbeat"
aria-label={`Run heartbeat for ${agent.name}`}
>
<Activity size={14} />
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
@@ -481,6 +501,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "running" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
disabled
title="Run in progress"
aria-label={`Heartbeat run in progress for ${agent.name}`}
>
<Activity size={14} />
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
@@ -641,6 +671,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "active" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
title="Run Heartbeat"
aria-label={`Run heartbeat for ${agent.name}`}
>
<Activity size={14} /> Run
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
@@ -677,6 +717,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "running" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
disabled
title="Run in progress"
aria-label={`Heartbeat run in progress for ${agent.name}`}
>
<Activity size={14} /> Running
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}

View File

@@ -13,6 +13,9 @@ vi.mock("../../api", () => ({
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
fetchAgentRunLogs: vi.fn(),
fetchAgentRuns: vi.fn(),
fetchAgentRunDetail: vi.fn(),
startAgentRun: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -23,12 +26,14 @@ vi.mock("../AgentLogViewer", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs } from "../../api";
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
@@ -71,9 +76,16 @@ describe("AgentDetailView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchAgent.mockResolvedValue(createMockAgent());
const mockAgent = createMockAgent();
mockFetchAgent.mockResolvedValue(mockAgent);
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
// Default: return runs from mock agent
mockFetchAgentRuns.mockResolvedValue([
...(mockAgent.activeRun ? [mockAgent.activeRun] : []),
...mockAgent.completedRuns,
]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
});
it("shows loading state initially", () => {
@@ -1077,6 +1089,7 @@ describe("AgentDetailView", () => {
it("shows toast on fetch error", async () => {
const addToast = vi.fn();
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
mockFetchAgentRunDetail.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
@@ -1103,7 +1116,7 @@ describe("AgentDetailView", () => {
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to load run logs"),
expect.stringContaining("Failed to load run details"),
"error",
);
});

View File

@@ -0,0 +1,358 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request } from "../test-request.js";
// ── Mock @fusion/core for agent runs ─────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockStartHeartbeatRun = vi.fn();
const mockSaveRun = vi.fn();
const mockGetRecentRuns = vi.fn();
const mockGetRunDetail = vi.fn();
const mockRecordHeartbeat = vi.fn();
const mockUpdateAgentState = vi.fn();
const mockListAgents = vi.fn().mockResolvedValue([]);
vi.mock("@fusion/core", () => {
return {
AgentStore: class MockAgentStore {
init = mockInit;
startHeartbeatRun = mockStartHeartbeatRun;
saveRun = mockSaveRun;
getRecentRuns = mockGetRecentRuns;
getRunDetail = mockGetRunDetail;
recordHeartbeat = mockRecordHeartbeat;
updateAgentState = mockUpdateAgentState;
listAgents = mockListAgents;
},
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1059-test";
}
getFusionDir(): string {
return "/tmp/fn-1059-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
// ── Test helpers ──────────────────────────────────────────────────────
function createMockRun(overrides: Record<string, unknown> = {}) {
return {
id: "run-001",
agentId: "agent-001",
startedAt: "2026-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("Agent runs routes (without HeartbeatMonitor)", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
beforeEach(async () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("POST /api/agents/:id/runs", () => {
it("returns 201 with run record (fallback behavior without HeartbeatMonitor)", async () => {
const mockRun = createMockRun();
mockStartHeartbeatRun.mockResolvedValue(mockRun);
mockSaveRun.mockResolvedValue(undefined);
const response = await request(
app,
"POST",
"/api/agents/agent-001/runs",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect((response.body as any).id).toBe("run-001");
expect((response.body as any).invocationSource).toBe("on_demand");
});
it("enriches run with source and triggerDetail from body", async () => {
const mockRun = createMockRun();
mockStartHeartbeatRun.mockResolvedValue(mockRun);
mockSaveRun.mockResolvedValue(undefined);
const response = await request(
app,
"POST",
"/api/agents/agent-001/runs",
JSON.stringify({ source: "timer", triggerDetail: "Scheduled check" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect((response.body as any).invocationSource).toBe("timer");
});
it("returns 404 when agent not found", async () => {
mockStartHeartbeatRun.mockRejectedValue(new Error("Agent agent-999 not found"));
const response = await request(
app,
"POST",
"/api/agents/agent-999/runs",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
expect((response.body as any).error).toContain("not found");
});
});
describe("POST /api/agents/:id/heartbeat", () => {
it("records heartbeat and returns event", async () => {
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
mockRecordHeartbeat.mockResolvedValue(mockEvent);
const response = await request(
app,
"POST",
"/api/agents/agent-001/heartbeat",
JSON.stringify({ status: "ok" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).id).toBe("evt-001");
expect(mockRecordHeartbeat).toHaveBeenCalledWith("agent-001", "ok");
});
it("records heartbeat with default status when not provided", async () => {
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
mockRecordHeartbeat.mockResolvedValue(mockEvent);
const response = await request(
app,
"POST",
"/api/agents/agent-001/heartbeat",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(mockRecordHeartbeat).toHaveBeenCalledWith("agent-001", "ok");
});
it("returns 404 when agent not found", async () => {
mockRecordHeartbeat.mockRejectedValue(new Error("Agent not found"));
const response = await request(
app,
"POST",
"/api/agents/agent-999/heartbeat",
JSON.stringify({ status: "ok" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
});
it("without HeartbeatMonitor, triggerExecution does nothing extra", async () => {
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
mockRecordHeartbeat.mockResolvedValue(mockEvent);
const response = await request(
app,
"POST",
"/api/agents/agent-001/heartbeat",
JSON.stringify({ status: "ok", triggerExecution: true }),
{ "content-type": "application/json" },
);
// Returns just the event (no run since no HeartbeatMonitor)
expect(response.status).toBe(200);
expect((response.body as any).id).toBe("evt-001");
});
});
describe("GET /api/agents/:id/runs", () => {
it("returns run list", async () => {
const mockRuns = [
createMockRun({ id: "run-001", status: "completed", endedAt: "2026-01-01T00:05:00.000Z" }),
createMockRun({ id: "run-002", status: "active" }),
];
mockGetRecentRuns.mockResolvedValue(mockRuns);
const response = await request(app, "GET", "/api/agents/agent-001/runs");
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect((response.body as any[]).length).toBe(2);
});
it("respects limit query parameter", async () => {
mockGetRecentRuns.mockResolvedValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs?limit=5");
expect(response.status).toBe(200);
expect(mockGetRecentRuns).toHaveBeenCalledWith("agent-001", 5);
});
});
describe("GET /api/agents/:id/runs/:runId", () => {
it("returns detailed run", async () => {
const mockRun = createMockRun({
id: "run-001",
status: "completed",
endedAt: "2026-01-01T00:05:00.000Z",
stdoutExcerpt: "Task completed successfully",
usageJson: { inputTokens: 100, outputTokens: 50, cachedTokens: 0 },
});
mockGetRunDetail.mockResolvedValue(mockRun);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-001");
expect(response.status).toBe(200);
expect((response.body as any).id).toBe("run-001");
expect((response.body as any).stdoutExcerpt).toBe("Task completed successfully");
});
it("returns 404 when run not found", async () => {
mockGetRunDetail.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-999");
expect(response.status).toBe(404);
expect((response.body as any).error).toBe("Run not found");
});
});
});
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 () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
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,
},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("POST /api/agents/:id/runs", () => {
it("delegates to heartbeatMonitor.startRun 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(
app,
"POST",
"/api/agents/agent-001/runs",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
source: "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",
});
});
it("passes custom source and triggerDetail to heartbeatMonitor", async () => {
const mockRun = createMockRun();
mockStartRun.mockResolvedValue(mockRun);
mockExecuteHeartbeat.mockResolvedValue(mockRun);
await request(
app,
"POST",
"/api/agents/agent-001/runs",
JSON.stringify({ source: "timer", triggerDetail: "Scheduled run" }),
{ "content-type": "application/json" },
);
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
source: "timer",
triggerDetail: "Scheduled run",
});
});
});
describe("POST /api/agents/:id/heartbeat with triggerExecution", () => {
it("triggers execution when triggerExecution=true and HeartbeatMonitor available", async () => {
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(
app,
"POST",
"/api/agents/agent-001/heartbeat",
JSON.stringify({ status: "ok", triggerExecution: true }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(mockStartRun).toHaveBeenCalled();
expect(mockExecuteHeartbeat).toHaveBeenCalled();
// Response should include both event and run
expect((response.body as any).event).toBeDefined();
expect((response.body as any).run).toBeDefined();
});
});
});

View File

@@ -1336,6 +1336,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Get GitHub token from options or env
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
// HeartbeatMonitor for triggering agent execution runs
const heartbeatMonitor = options?.heartbeatMonitor;
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
// Scheduler config (includes persisted settings)
router.get("/config", async (req, res) => {
try {
@@ -7110,10 +7114,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
/**
* POST /api/agents/:id/heartbeat
* Record a heartbeat for an agent.
* 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.
*/
router.post("/agents/:id/heartbeat", async (req, res) => {
try {
const { status = "ok" } = req.body;
const { status = "ok", triggerExecution } = req.body;
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
@@ -7121,7 +7129,26 @@ Output ONLY the prompt text (no markdown, no explanations).`;
await agentStore.init();
const event = await agentStore.recordHeartbeat(req.params.id, status as "ok" | "missed" | "recovered");
res.json(event);
// 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({
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);
});
}
res.json(run ? { event, run } : event);
} catch (err: any) {
if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });
@@ -7180,30 +7207,54 @@ Output ONLY the prompt text (no markdown, no explanations).`;
* POST /api/agents/:id/runs
* Manually start a heartbeat run for an agent.
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: 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.
*/
router.post("/agents/:id/runs", async (req, res) => {
try {
const { source, triggerDetail } = req.body || {};
const invocationSource = source ?? "on_demand";
const trigger = triggerDetail ?? "Triggered from dashboard";
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
if (hasHeartbeatExecutor && heartbeatMonitor) {
// Delegate to HeartbeatMonitor for enriched run creation
const run = await heartbeatMonitor.startRun(req.params.id, {
source: invocationSource,
triggerDetail: trigger,
});
const run = await agentStore.startHeartbeatRun(req.params.id);
// Fire-and-forget execution in the background
void heartbeatMonitor.executeHeartbeat({
agentId: req.params.id,
source: invocationSource,
triggerDetail: trigger,
}).catch((err: any) => {
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
});
// Enrich with invocation source and trigger detail
if (source) {
(run as any).invocationSource = source;
res.status(201).json(run);
} else {
(run as any).invocationSource = "on_demand";
}
if (triggerDetail) {
(run as any).triggerDetail = triggerDetail;
}
// Fallback: record-only behavior without HeartbeatMonitor
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
await agentStore.saveRun(run);
res.status(201).json(run);
const run = await agentStore.startHeartbeatRun(req.params.id);
// Enrich with invocation source and trigger detail
(run as any).invocationSource = invocationSource;
if (triggerDetail) {
(run as any).triggerDetail = triggerDetail;
}
await agentStore.saveRun(run);
res.status(201).json(run);
}
} catch (err: any) {
if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });

View File

@@ -50,6 +50,11 @@ export interface ServerOptions {
start(): void;
stop(): void;
};
/** 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>;
};
}
type DashboardExpressApp = ReturnType<typeof express> & {