feat(FN-2211): suppress redundant agent health labels
- Add stateDerived metadata to AgentHealthStatus to indicate when health text only mirrors agent.state - Update getAgentHealthStatus to set stateDerived consistently across terminated, error, paused, running, heartbeat, idle, and disabled paths - Update AgentDetailView, AgentListModal, and AgentsView to render icon-only health badges when labels are state-derived while preserving full labels via title tooltips - Expand agentHealth tests with explicit stateDerived assertions and a table-driven semantics suite for representative states
This commit is contained in:
@@ -14,6 +14,7 @@ import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
import type { AgentHealthStatus } from "../utils/agentHealth";
|
||||
import { SkillMultiselect } from "./SkillMultiselect";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval } from "../utils/heartbeatIntervals";
|
||||
@@ -326,8 +327,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
};
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
const getHealthStatus = () => {
|
||||
if (!agent) return { label: "Unknown", color: "var(--text-muted, #8b949e)" };
|
||||
const getHealthStatus = (): AgentHealthStatus => {
|
||||
if (!agent) {
|
||||
return {
|
||||
label: "Unknown",
|
||||
icon: <Bot size={14} />,
|
||||
color: "var(--text-muted, #8b949e)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
return getAgentHealthStatus(agent);
|
||||
};
|
||||
|
||||
@@ -377,9 +386,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="badge" style={{ color: health.color }}>
|
||||
{"icon" in health ? health.icon : null}
|
||||
{health.label}
|
||||
<span className="badge" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}
|
||||
{!health.stateDerived && health.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -620,7 +629,7 @@ function DashboardTab({
|
||||
projectId,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
health: { label: string; color: string };
|
||||
health: AgentHealthStatus;
|
||||
onChildClick?: (childId: string) => void;
|
||||
projectId?: string;
|
||||
}) {
|
||||
@@ -748,8 +757,8 @@ function DashboardTab({
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Health</span>
|
||||
<span className="info-value" style={{ color: health.color }}>
|
||||
{health.label}
|
||||
<span className="info-value" style={{ color: health.color }} title={health.label}>
|
||||
{!health.stateDerived ? health.label : health.icon}
|
||||
</span>
|
||||
</div>
|
||||
{modelDisplay && (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
import type { AgentHealthStatus } from "../utils/agentHealth";
|
||||
|
||||
interface AgentListModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -169,7 +169,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
// This fixes the previous hardcoded 60s timeout that was inconsistent with other views
|
||||
const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
|
||||
const getHealthStatus = (agent: Agent): AgentHealthStatus => {
|
||||
return getAgentHealthStatus(agent);
|
||||
};
|
||||
|
||||
@@ -473,8 +473,8 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="badge" style={{ color: health.color }}>
|
||||
{health.icon} {health.label}
|
||||
<span className="badge" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
||||
</span>
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree } from "../api";
|
||||
@@ -14,6 +13,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
import type { AgentHealthStatus } from "../utils/agentHealth";
|
||||
import {
|
||||
formatHeartbeatInterval,
|
||||
getHeartbeatIntervalOptions,
|
||||
@@ -62,7 +62,7 @@ function AgentTreeNode({
|
||||
onToggle: (id: string) => void;
|
||||
isExpanded: (id: string) => boolean;
|
||||
getChildCount: (id: string) => number;
|
||||
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
|
||||
getHealthStatus: (agent: Agent) => AgentHealthStatus;
|
||||
getRoleIcon: (role: AgentCapability) => string;
|
||||
getSkillBadges: (agent: Agent) => string[];
|
||||
}) {
|
||||
@@ -156,7 +156,7 @@ function OrgChartNode({
|
||||
}: {
|
||||
node: OrgTreeNode;
|
||||
onSelect: (id: string) => void;
|
||||
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
|
||||
getHealthStatus: (agent: Agent) => AgentHealthStatus;
|
||||
getRoleIcon: (role: AgentCapability) => string;
|
||||
getSkillBadges: (agent: Agent) => string[];
|
||||
}) {
|
||||
@@ -190,7 +190,7 @@ function OrgChartNode({
|
||||
</span>
|
||||
<span className="org-chart-node__health" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}
|
||||
<span className="text-secondary">{health.label}</span>
|
||||
{!health.stateDerived && <span className="text-secondary">{health.label}</span>}
|
||||
</span>
|
||||
{/* Org chart: up to 2 skill badges */}
|
||||
{(() => {
|
||||
@@ -475,7 +475,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
};
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
|
||||
const getHealthStatus = (agent: Agent): AgentHealthStatus => {
|
||||
return getAgentHealthStatus(agent);
|
||||
};
|
||||
|
||||
@@ -914,8 +914,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="badge" style={{ color: health.color }}>
|
||||
{health.icon} {health.label}
|
||||
<span className="badge" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
||||
</span>
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
@@ -52,6 +53,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +62,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "error" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Error");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
@@ -67,6 +70,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "error", lastError: "Agent crashed" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Agent crashed");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for error agents", () => {
|
||||
@@ -76,6 +80,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Error");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +89,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "paused" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-paused-text)");
|
||||
});
|
||||
|
||||
@@ -91,6 +97,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "paused", pauseReason: "User requested" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused: User requested");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for paused agents", () => {
|
||||
@@ -100,6 +107,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +116,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "running" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
@@ -118,6 +127,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +141,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
@@ -142,6 +153,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Disabled" for idle agents with monitoring disabled', () => {
|
||||
@@ -151,6 +163,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +184,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
@@ -185,6 +199,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
@@ -197,6 +212,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +223,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "active" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Starting...");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
@@ -214,6 +231,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "idle" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Idle");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
@@ -222,6 +240,7 @@ describe("getAgentHealthStatus", () => {
|
||||
const agent = makeAgent({ state: "idle", lastHeartbeatAt: undefined });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Idle");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +255,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
@@ -247,6 +267,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Unresponsive" when heartbeat exceeds the timeout with periodic heartbeat', () => {
|
||||
@@ -257,6 +278,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
@@ -269,6 +291,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("marks as unresponsive when exceeding per-agent timeout", () => {
|
||||
@@ -280,6 +303,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,6 +319,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
@@ -306,6 +331,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: 0 (invalid, treated as non-periodic)', () => {
|
||||
@@ -316,6 +342,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: -5000 (negative, treated as non-periodic)', () => {
|
||||
@@ -326,6 +353,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Healthy" for agent with heartbeatIntervalMs: undefined (non-periodic)', () => {
|
||||
@@ -336,6 +364,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Healthy" for periodic agent with heartbeatIntervalMs: 60000 and stale heartbeat shows "Unresponsive"', () => {
|
||||
@@ -346,6 +375,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,6 +389,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 'Healthy' for agent with runtimeConfig but no heartbeatIntervalMs", () => {
|
||||
@@ -369,6 +400,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles custom timeout of 30 seconds with periodic heartbeat", () => {
|
||||
@@ -379,6 +411,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles custom timeout of 120 seconds with periodic heartbeat", () => {
|
||||
@@ -389,6 +422,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles very short timeout of 5 seconds with periodic heartbeat", () => {
|
||||
@@ -399,6 +433,86 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stateDerived semantics", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "paused without reason",
|
||||
agent: makeAgent({ state: "paused" }),
|
||||
expectedLabel: "Paused",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "paused with reason",
|
||||
agent: makeAgent({ state: "paused", pauseReason: "Backoff" }),
|
||||
expectedLabel: "Paused: Backoff",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "running",
|
||||
agent: makeAgent({ state: "running" }),
|
||||
expectedLabel: "Running",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "error without lastError",
|
||||
agent: makeAgent({ state: "error" }),
|
||||
expectedLabel: "Error",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "error with lastError",
|
||||
agent: makeAgent({ state: "error", lastError: "OOM" }),
|
||||
expectedLabel: "OOM",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "terminated",
|
||||
agent: makeAgent({ state: "terminated" }),
|
||||
expectedLabel: "Terminated",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "healthy",
|
||||
agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 10_000).toISOString() }),
|
||||
expectedLabel: "Healthy",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "unresponsive",
|
||||
agent: makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 120_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 },
|
||||
}),
|
||||
expectedLabel: "Unresponsive",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "idle",
|
||||
agent: makeAgent({ state: "idle", lastHeartbeatAt: undefined }),
|
||||
expectedLabel: "Idle",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "starting",
|
||||
agent: makeAgent({ state: "active", lastHeartbeatAt: undefined }),
|
||||
expectedLabel: "Starting...",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }),
|
||||
expectedLabel: "Disabled",
|
||||
expectedStateDerived: false,
|
||||
},
|
||||
])("sets stateDerived correctly for $name", ({ agent, expectedLabel, expectedStateDerived }) => {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe(expectedLabel);
|
||||
expect(status.stateDerived).toBe(expectedStateDerived);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,6 +527,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty runtimeConfig object", () => {
|
||||
@@ -423,6 +538,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled as true when undefined (non-periodic)", () => {
|
||||
@@ -433,6 +549,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy"); // non-periodic agents are always Healthy when they have heartbeat
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled === true with periodic heartbeat", () => {
|
||||
@@ -443,6 +560,7 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy"); // within 120s timeout
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it("returns consistent icons for all states", () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AgentHealthStatus {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
color: string;
|
||||
/** True when label only mirrors agent.state and adds no extra context */
|
||||
stateDerived: boolean;
|
||||
}
|
||||
|
||||
type AgentHealthInput = Pick<
|
||||
@@ -90,7 +92,7 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
* - "Unresponsive" — heartbeat exceeded the configured timeout
|
||||
*
|
||||
* @param agent - The agent object (partial Agent shape is accepted)
|
||||
* @returns A health status object with label, icon, and color
|
||||
* @returns A health status object with label, icon, color, and stateDerived metadata
|
||||
*/
|
||||
export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus {
|
||||
const { state, lastHeartbeatAt, lastError, pauseReason, runtimeConfig } = agent;
|
||||
@@ -102,6 +104,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: "Terminated",
|
||||
icon: <Square size={14} />,
|
||||
color: "var(--state-error-text)",
|
||||
stateDerived: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +113,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: lastError ?? "Error",
|
||||
icon: <Activity size={14} />,
|
||||
color: "var(--state-error-text)",
|
||||
stateDerived: !lastError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +123,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label,
|
||||
icon: <Pause size={14} />,
|
||||
color: "var(--state-paused-text)",
|
||||
stateDerived: !pauseReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,6 +132,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: "Running",
|
||||
icon: <Activity size={14} />,
|
||||
color: "var(--state-active-text)",
|
||||
stateDerived: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +142,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: "Disabled",
|
||||
icon: <Bot size={14} />,
|
||||
color: "var(--text-secondary)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,6 +152,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: state === "active" ? "Starting..." : "Idle",
|
||||
icon: <Bot size={14} />,
|
||||
color: "var(--text-secondary)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,6 +165,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: "Healthy",
|
||||
icon: <Heart size={14} />,
|
||||
color: "var(--state-active-text)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +179,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: "Unresponsive",
|
||||
icon: <Activity size={14} />,
|
||||
color: "var(--state-error-text)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,6 +187,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
label: "Healthy",
|
||||
icon: <Heart size={14} />,
|
||||
color: "var(--state-active-text)",
|
||||
stateDerived: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user