fix(FN-2199): normalize heartbeat settings dirty state in agent config

- Display heartbeat interval and timeout in seconds while persisting runtimeConfig in milliseconds
- Convert derived and persisted heartbeat values before dirty-state comparison so unchanged settings keep Save Settings disabled
- Update heartbeat validation minimums, placeholders, and hints to align with seconds-based inputs
- Expand AgentDetailView tests for pre-filled values, unchanged-state save disabling, validation, and payload conversion
This commit is contained in:
Fusion
2026-04-20 18:44:40 -07:00
committed by gsxdsm
parent e6b2685e24
commit 64f5c0862b
2 changed files with 70 additions and 37 deletions

View File

@@ -2625,10 +2625,10 @@ function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | und
const nextValues: Record<string, string> = {};
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
nextValues.heartbeatIntervalMs = String(rc.heartbeatIntervalMs);
nextValues.heartbeatIntervalMs = String(Number(rc.heartbeatIntervalMs) / 1000);
}
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
nextValues.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
nextValues.heartbeatTimeoutMs = String(Number(rc.heartbeatTimeoutMs) / 1000);
}
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
nextValues.maxConcurrentRuns = String(rc.maxConcurrentRuns);
@@ -2811,7 +2811,12 @@ function ConfigTab({
const rc = agent.runtimeConfig ?? {};
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
const current = heartbeatValues[key]?.trim() ?? "";
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
if ((key === "heartbeatIntervalMs" || key === "heartbeatTimeoutMs") && persisted) {
persisted = String(Number(persisted) / 1000);
}
if (current !== persisted) return true;
}
// Check budget config values
@@ -2920,8 +2925,8 @@ function ConfigTab({
// Validate heartbeat settings
for (const [key, config] of Object.entries({
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1000 },
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5000 },
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1 },
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5 },
maxConcurrentRuns: { label: "Max Concurrent Runs", min: 1 },
})) {
const raw = heartbeatValues[key]?.trim();
@@ -3016,7 +3021,8 @@ function ConfigTab({
if (!raw) {
delete newRuntimeConfig[key];
} else {
newRuntimeConfig[key] = Number(raw);
const num = Number(raw);
newRuntimeConfig[key] = key === "maxConcurrentRuns" ? num : num * 1000;
}
}
@@ -3231,13 +3237,13 @@ function ConfigTab({
<div className="config-fields">
<div className="config-field">
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (ms)</label>
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
<input
id="hb-heartbeatIntervalMs"
type="text"
inputMode="numeric"
className={cn("input", !!errors.heartbeatIntervalMs && "input--error")}
placeholder={String(DEFAULT_HEARTBEAT_INTERVAL_MS)}
placeholder={String(DEFAULT_HEARTBEAT_INTERVAL_MS / 1000)}
value={heartbeatValues.heartbeatIntervalMs ?? ""}
onChange={(e) => handleHeartbeatFieldChange("heartbeatIntervalMs", e.target.value)}
/>
@@ -3245,26 +3251,26 @@ function ConfigTab({
<span className="config-error">{errors.heartbeatIntervalMs}</span>
) : (
<span className="config-hint">
How often heartbeats are checked. Leave empty for system default ({DEFAULT_HEARTBEAT_INTERVAL_MS}ms / {DEFAULT_HEARTBEAT_INTERVAL_LABEL}).
How often heartbeats are checked. Leave empty for system default ({DEFAULT_HEARTBEAT_INTERVAL_MS / 1000}s / {DEFAULT_HEARTBEAT_INTERVAL_LABEL}).
</span>
)}
</div>
<div className="config-field">
<label htmlFor="hb-heartbeatTimeoutMs">Heartbeat Timeout (ms)</label>
<label htmlFor="hb-heartbeatTimeoutMs">Heartbeat Timeout (s)</label>
<input
id="hb-heartbeatTimeoutMs"
type="text"
inputMode="numeric"
className={cn("input", !!errors.heartbeatTimeoutMs && "input--error")}
placeholder="60000"
placeholder="60"
value={heartbeatValues.heartbeatTimeoutMs ?? ""}
onChange={(e) => handleHeartbeatFieldChange("heartbeatTimeoutMs", e.target.value)}
/>
{errors.heartbeatTimeoutMs ? (
<span className="config-error">{errors.heartbeatTimeoutMs}</span>
) : (
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60000ms)</span>
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60s)</span>
)}
</div>

View File

@@ -991,8 +991,8 @@ describe("AgentDetailView", () => {
await waitFor(() => {
// Heartbeat Settings section
expect(screen.getByLabelText("Heartbeat Interval (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Heartbeat Timeout (ms)")).toBeInTheDocument();
expect(screen.getByLabelText("Heartbeat Interval (s)")).toBeInTheDocument();
expect(screen.getByLabelText("Heartbeat Timeout (s)")).toBeInTheDocument();
// Advanced Settings section
expect(screen.getByLabelText("Max Retries")).toBeInTheDocument();
expect(screen.getByLabelText("Task Timeout (ms)")).toBeInTheDocument();
@@ -1118,7 +1118,7 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("");
});
});
@@ -1137,10 +1137,10 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
expect(heartbeatInput).toHaveAttribute("placeholder", String(DEFAULT_HEARTBEAT_INTERVAL_MS));
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
expect(heartbeatInput).toHaveAttribute("placeholder", String(DEFAULT_HEARTBEAT_INTERVAL_MS / 1000));
expect(
screen.getByText(`How often heartbeats are checked. Leave empty for system default (${DEFAULT_HEARTBEAT_INTERVAL_MS}ms / 1h).`),
screen.getByText(`How often heartbeats are checked. Leave empty for system default (${DEFAULT_HEARTBEAT_INTERVAL_MS / 1000}s / 1h).`),
).toBeInTheDocument();
});
@@ -1168,11 +1168,11 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
await waitFor(() => {
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("15000");
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)") as HTMLInputElement;
expect(heartbeatInput.value).toBe("15");
const heartbeatTimeoutInput = screen.getByLabelText("Heartbeat Timeout (ms)") as HTMLInputElement;
expect(heartbeatTimeoutInput.value).toBe("120000");
const heartbeatTimeoutInput = screen.getByLabelText("Heartbeat Timeout (s)") as HTMLInputElement;
expect(heartbeatTimeoutInput.value).toBe("120");
const retriesInput = screen.getByLabelText("Max Retries") as HTMLInputElement;
expect(retriesInput.value).toBe("5");
@@ -1201,6 +1201,33 @@ describe("AgentDetailView", () => {
});
});
it("keeps Save Settings disabled when heartbeat runtimeConfig values are pre-filled and unchanged", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
metadata: {},
runtimeConfig: {
heartbeatIntervalMs: 30000,
heartbeatTimeoutMs: 60000,
},
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect((screen.getByLabelText("Heartbeat Interval (s)") as HTMLInputElement).value).toBe("30");
expect((screen.getByLabelText("Heartbeat Timeout (s)") as HTMLInputElement).value).toBe("60");
expect(screen.getByText("Save Settings")).toBeDisabled();
});
});
it("enables Save Settings when a field is changed", async () => {
const user = userEvent.setup();
render(
@@ -1213,10 +1240,10 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await user.type(heartbeatInput, "15");
await waitFor(() => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
@@ -1237,7 +1264,7 @@ describe("AgentDetailView", () => {
// Simulate setting a non-numeric value via React's internal value setter
// (userEvent.type on type="number" rejects non-numeric chars, so we bypass it)
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (ms)")) as HTMLInputElement;
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (s)")) as HTMLInputElement;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set;
@@ -1263,15 +1290,15 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatTimeoutInput = await screen.findByLabelText("Heartbeat Timeout (ms)");
const heartbeatTimeoutInput = await screen.findByLabelText("Heartbeat Timeout (s)");
await user.clear(heartbeatTimeoutInput);
await user.type(heartbeatTimeoutInput, "500");
await user.type(heartbeatTimeoutInput, "4");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(screen.getByText(/must be at least 5,000/)).toBeInTheDocument();
expect(screen.getByText(/must be at least 5/)).toBeInTheDocument();
});
});
@@ -1314,10 +1341,10 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "15000");
await user.type(heartbeatInput, "15");
await user.click(screen.getByText("Save Settings"));
@@ -1351,10 +1378,10 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "20000");
await user.type(heartbeatInput, "20");
await user.click(screen.getByText("Save Settings"));
@@ -1438,7 +1465,7 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
// Type "abc" directly into a text input
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "abc");
@@ -1487,8 +1514,8 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
expect((heartbeatInput as HTMLInputElement).value).toBe("30000");
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
expect((heartbeatInput as HTMLInputElement).value).toBe("30");
await user.clear(heartbeatInput);
@@ -1523,10 +1550,10 @@ describe("AgentDetailView", () => {
await navigateToSettings(user);
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
await user.clear(heartbeatInput);
await user.type(heartbeatInput, "45000");
await user.type(heartbeatInput, "45");
await user.click(screen.getByText("Save Settings"));