fix(FN-2198): sync agent heartbeat config UI with live runtime updates

- Refresh AgentDetailView on agent:updated SSE events while preserving local unsaved config edits
- Resync heartbeat and budget form state from latest runtime config when agent data changes
- Centralize heartbeat interval defaults/formatting in shared utilities and reuse them in AgentsView selectors
- Add dashboard tests for default heartbeat hints, unset runtime fallback behavior, and custom interval options
This commit is contained in:
Fusion
2026-04-20 14:36:18 -07:00
committed by gsxdsm
parent 238c5e27ec
commit db8258f8bd
5 changed files with 234 additions and 79 deletions

View File

@@ -16,6 +16,7 @@ import { AgentReflectionsTab } from "./AgentReflectionsTab";
import { getAgentHealthStatus } from "../utils/agentHealth";
import { SkillMultiselect } from "./SkillMultiselect";
import { subscribeSse } from "../sse-bus";
import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval } from "../utils/heartbeatIntervals";
import { CustomModelDropdown } from "./CustomModelDropdown";
/**
@@ -101,6 +102,8 @@ const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
dreams: "Synthesized patterns and emerging themes distilled from this agent's daily memory.",
};
const DEFAULT_HEARTBEAT_INTERVAL_LABEL = formatHeartbeatInterval(DEFAULT_HEARTBEAT_INTERVAL_MS);
function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string): string {
if (files.some((file) => file.path === currentPath)) {
return currentPath;
@@ -121,6 +124,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const onCloseRef = useRef(onClose);
const addToastRef = useRef(addToast);
const agentRef = useRef<AgentDetail | null>(null);
const hasConfigChangesRef = useRef(false);
// Track the context version to detect stale events after project/agent switches.
// Incremented whenever agentId or projectId changes, invalidating any in-flight SSE handlers.
@@ -181,6 +185,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
}
}, [agent?.taskId, agentId, projectId]);
const handleConfigChangesState = useCallback((hasChanges: boolean) => {
hasConfigChangesRef.current = hasChanges;
}, []);
useEffect(() => {
void loadAgent();
}, [loadAgent]);
@@ -213,9 +221,37 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
// Clear stale logs and streaming state immediately
setLogs([]);
setIsStreaming(false);
hasConfigChangesRef.current = false;
}
}, [agentId, projectId]);
// Refresh this view when the current agent is updated elsewhere, unless there are unsaved edits.
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const contextVersionAtStart = contextVersionRef.current;
return subscribeSse(`/api/events${query}`, {
events: {
"agent:updated": (event) => {
if (contextVersionRef.current !== contextVersionAtStart) return;
try {
const payload: unknown = JSON.parse(event.data);
if (!payload || typeof payload !== "object") return;
const updatedId = (payload as { id?: unknown }).id;
if (updatedId !== agentId) return;
if (hasConfigChangesRef.current) return;
void loadAgent();
} catch {
// Ignore malformed events
}
},
},
});
}, [agentId, projectId, loadAgent]);
// Set up SSE for live log streaming when viewing logs tab with a task
useEffect(() => {
if (activeTab !== "logs" || !agent?.taskId) {
@@ -546,6 +582,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
projectId={projectId}
addToast={addToast}
onSaved={loadAgent}
onHasChangesChange={handleConfigChangesState}
/>
)}
</div>
@@ -2583,16 +2620,63 @@ function PerformanceTab({
);
}
function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
const rc = runtimeConfig ?? {};
const nextValues: Record<string, string> = {};
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
nextValues.heartbeatIntervalMs = String(rc.heartbeatIntervalMs);
}
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
nextValues.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
}
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
nextValues.maxConcurrentRuns = String(rc.maxConcurrentRuns);
}
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
nextValues.messageResponseMode = rc.messageResponseMode;
}
return nextValues;
}
function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
const nextValues: Record<string, string> = {};
if (!bc) {
return nextValues;
}
if (bc.tokenBudget !== undefined && bc.tokenBudget !== null) {
nextValues.tokenBudget = String(bc.tokenBudget);
}
if (bc.usageThreshold !== undefined && bc.usageThreshold !== null) {
// Convert fraction (0-1) to percentage (0-100) for display
nextValues.usageThreshold = String(Number(bc.usageThreshold) * 100);
}
if (bc.budgetPeriod !== undefined && bc.budgetPeriod !== null) {
nextValues.budgetPeriod = String(bc.budgetPeriod);
}
if (bc.resetDay !== undefined && bc.resetDay !== null) {
nextValues.resetDay = String(bc.resetDay);
}
return nextValues;
}
function ConfigTab({
agent,
projectId,
addToast,
onSaved,
onHasChangesChange,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
onHasChangesChange?: (hasChanges: boolean) => void;
}) {
// Identity field state
const [nameValue, setNameValue] = useState(agent.name);
@@ -2614,45 +2698,14 @@ function ConfigTab({
});
// Heartbeat config state initialised from agent.runtimeConfig
const [heartbeatValues, setHeartbeatValues] = useState<Record<string, string>>(() => {
const rc = agent.runtimeConfig ?? {};
const initial: Record<string, string> = {};
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
initial.heartbeatIntervalMs = String(rc.heartbeatIntervalMs);
}
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
}
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
initial.maxConcurrentRuns = String(rc.maxConcurrentRuns);
}
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
initial.messageResponseMode = rc.messageResponseMode;
}
return initial;
});
const [heartbeatValues, setHeartbeatValues] = useState<Record<string, string>>(
() => deriveHeartbeatValues(agent.runtimeConfig),
);
// Budget config state initialised from agent.runtimeConfig.budgetConfig
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(() => {
const bc = (agent.runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
const initial: Record<string, string> = {};
if (bc !== undefined && bc !== null) {
if (bc.tokenBudget !== undefined && bc.tokenBudget !== null) {
initial.tokenBudget = String(bc.tokenBudget);
}
if (bc.usageThreshold !== undefined && bc.usageThreshold !== null) {
// Convert fraction (0-1) to percentage (0-100) for display
initial.usageThreshold = String(Number(bc.usageThreshold) * 100);
}
if (bc.budgetPeriod !== undefined && bc.budgetPeriod !== null) {
initial.budgetPeriod = String(bc.budgetPeriod);
}
if (bc.resetDay !== undefined && bc.resetDay !== null) {
initial.resetDay = String(bc.resetDay);
}
}
return initial;
});
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(
() => deriveBudgetValues(agent.runtimeConfig),
);
// Bundle config state
const [bundleMode, setBundleMode] = useState<string>(agent.bundleConfig?.mode ?? "");
@@ -2722,6 +2775,7 @@ function ConfigTab({
const [errors, setErrors] = useState<ValidationErrors>({});
const [justSaved, setJustSaved] = useState(false);
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousAgentRuntimeSyncRef = useRef<{ id: string; updatedAt: string } | null>(null);
useEffect(() => {
return () => {
@@ -2786,6 +2840,43 @@ function ConfigTab({
return false;
})();
const previousHasChangesRef = useRef<boolean | null>(null);
useEffect(() => {
if (!onHasChangesChange) return;
if (previousHasChangesRef.current === hasChanges) return;
previousHasChangesRef.current = hasChanges;
onHasChangesChange(hasChanges);
}, [hasChanges, onHasChangesChange]);
useEffect(() => {
return () => {
onHasChangesChange?.(false);
};
}, [onHasChangesChange]);
useEffect(() => {
const nextSnapshot = { id: agent.id, updatedAt: agent.updatedAt };
const previousSnapshot = previousAgentRuntimeSyncRef.current;
const hasNewAgentData =
!previousSnapshot
|| previousSnapshot.id !== nextSnapshot.id
|| previousSnapshot.updatedAt !== nextSnapshot.updatedAt;
if (!hasNewAgentData) {
return;
}
if (hasChanges) {
return;
}
previousAgentRuntimeSyncRef.current = nextSnapshot;
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
}, [agent, hasChanges]);
const handleFieldChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
setJustSaved(false);
@@ -3146,14 +3237,16 @@ function ConfigTab({
type="text"
inputMode="numeric"
className={cn("input", !!errors.heartbeatIntervalMs && "input--error")}
placeholder="30000"
placeholder={String(DEFAULT_HEARTBEAT_INTERVAL_MS)}
value={heartbeatValues.heartbeatIntervalMs ?? ""}
onChange={(e) => handleHeartbeatFieldChange("heartbeatIntervalMs", e.target.value)}
/>
{errors.heartbeatIntervalMs ? (
<span className="config-error">{errors.heartbeatIntervalMs}</span>
) : (
<span className="config-hint">How often heartbeats are checked. Leave empty for system default (30000ms)</span>
<span className="config-hint">
How often heartbeats are checked. Leave empty for system default ({DEFAULT_HEARTBEAT_INTERVAL_MS}ms / {DEFAULT_HEARTBEAT_INTERVAL_LABEL}).
</span>
)}
</div>

View File

@@ -14,6 +14,11 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { AgentImportModal } from "./AgentImportModal";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getAgentHealthStatus } from "../utils/agentHealth";
import {
formatHeartbeatInterval,
getHeartbeatIntervalOptions,
resolveHeartbeatIntervalMs,
} from "../utils/heartbeatIntervals";
import { isEphemeralAgent } from "@fusion/core";
export interface AgentsViewProps {
@@ -31,34 +36,6 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
{ value: "custom", label: "Custom", icon: "✦" },
];
const HEARTBEAT_INTERVAL_PRESETS = [
{ value: 1000, label: "1s" },
{ value: 5000, label: "5s" },
{ value: 10000, label: "10s" },
{ value: 30000, label: "30s" },
{ value: 60000, label: "1m" },
{ value: 300000, label: "5m" },
{ value: 900000, label: "15m" },
{ value: 1800000, label: "30m" },
{ value: 3600000, label: "1h" },
{ value: 10800000, label: "3h" },
{ value: 21600000, label: "6h" },
{ value: 43200000, label: "12h" },
{ value: 86400000, label: "24h" },
] as const;
function formatInterval(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
return `${Math.round(ms / 3_600_000)}h`;
}
function getClosestHeartbeatPreset(ms: number): number {
return HEARTBEAT_INTERVAL_PRESETS.reduce<number>((closest, preset) => {
return Math.abs(preset.value - ms) < Math.abs(closest - ms) ? preset.value : closest;
}, HEARTBEAT_INTERVAL_PRESETS[0].value);
}
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
@@ -454,7 +431,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
},
projectId,
);
addToast(`Heartbeat interval updated to ${formatInterval(newIntervalMs)} for ${agent.name}`, "success");
addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(newIntervalMs)} for ${agent.name}`, "success");
void loadAgents();
} catch (err: any) {
addToast(`Failed to update heartbeat interval: ${err.message}`, "error");
@@ -870,11 +847,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const stateStyle = STATE_COLORS[agent.state];
const configuredIntervalMs =
typeof agent.runtimeConfig?.heartbeatIntervalMs === "number"
? Math.max(1000, Math.round(agent.runtimeConfig.heartbeatIntervalMs))
: 3_600_000;
const selectedIntervalMs = getClosestHeartbeatPreset(configuredIntervalMs);
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs);
const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
return (
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
@@ -972,15 +946,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
<div className="agent-heartbeat-control">
<span className="text-secondary">Heartbeat:</span>
<span className="badge text-secondary">{formatInterval(configuredIntervalMs)}</span>
<span className="badge text-secondary">{formatHeartbeatInterval(configuredIntervalMs)}</span>
<select
className="select agent-heartbeat-select"
value={selectedIntervalMs}
value={configuredIntervalMs}
onChange={(e) => void handleHeartbeatIntervalChange(agent, Number(e.target.value))}
disabled={isUpdatingHeartbeat}
aria-label={`Set heartbeat interval for ${agent.name}`}
>
{HEARTBEAT_INTERVAL_PRESETS.map((preset) => (
{heartbeatOptions.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>

View File

@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { AgentDetailView } from "../AgentDetailView";
import type { AgentCapability, AgentDetail } from "../../api";
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
// Mock the API functions
vi.mock("../../api", () => ({
@@ -1122,6 +1123,27 @@ describe("AgentDetailView", () => {
});
});
it("shows shared system default hint for heartbeat interval", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
expect(heartbeatInput).toHaveAttribute("placeholder", String(DEFAULT_HEARTBEAT_INTERVAL_MS));
expect(
screen.getByText(`How often heartbeats are checked. Leave empty for system default (${DEFAULT_HEARTBEAT_INTERVAL_MS}ms / 1h).`),
).toBeInTheDocument();
});
it("pre-fills heartbeat fields from agent runtimeConfig", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {

View File

@@ -181,6 +181,25 @@ describe("AgentsView", () => {
expect(screen.getByDisplayValue("30s")).toBeTruthy();
});
it("uses the system default heartbeat interval when runtime config is unset", async () => {
mockFetchAgents.mockResolvedValue([
{
...mockAgents[1],
runtimeConfig: {},
},
]);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
});
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
expect(intervalSelect.value).toBe("3600000");
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1h");
});
it("updates agent heartbeat interval from preset dropdown", async () => {
render(<AgentsView addToast={mockAddToast} />);
@@ -202,7 +221,7 @@ describe("AgentsView", () => {
});
});
it("maps non-preset heartbeat interval to closest preset", async () => {
it("shows a custom heartbeat option when configured interval is not a preset", async () => {
mockFetchAgents.mockResolvedValue([
{
...mockAgents[1],
@@ -217,8 +236,9 @@ describe("AgentsView", () => {
});
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
expect(intervalSelect.value).toBe("60000");
expect(screen.getAllByText("1m").length).toBeGreaterThan(0);
expect(intervalSelect.value).toBe("65000");
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1m (custom)");
expect(screen.getByRole("option", { name: "1m (custom)" })).toBeTruthy();
});
it("shows refresh button", async () => {