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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user