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:
Fusion
2026-04-21 08:05:20 -07:00
committed by gsxdsm
parent 2c7e575b9f
commit c9592050a6
5 changed files with 158 additions and 20 deletions

View File

@@ -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 && (

View File

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

View File

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

View File

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

View File

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