refactor(coding-agent): derive scoped heartbeats at render time (#1857)
* refactor(coding-agent): derive scoped heartbeats at render time * fix(coding-agent): keep the earliest heartbeat manager refresh deadline scheduleHeartbeatManagerRefresh now runs on every subagent snapshot update, and each call cleared and re-armed the refresh timer. An overdue heartbeat uses a relative 5s fallback delay, so children updating more often than every 5s postponed the open manager's refresh indefinitely. Track the scheduled deadline and keep the pending timer unless the newly derived deadline is earlier. * test(coding-agent): cover re-arming the heartbeat refresh to an earlier deadline The keep-timer-always mutant (returning whenever a timer is armed) survived the touched suites: nothing proved a sooner heartbeat pulls the pending refresh forward past the capped 60s poll.
1- Kept heartbeat lists current when session or subagent scope changes.46 unmodified lines4748495051525152535455561 unmodified line58596061626362636465666768697012 unmodified lines83848586878889909192939495969798991001011021038610410510610710810922 unmodified lines13213313413511713613713813914014119 unmodified lines46 unmodified lines expect(KEYBINDINGS["app.heartbeats.openSelected"].defaultKeys).toBe("right"); });
it("groups user and agent heartbeats and stays within terminal width", () => { const component = new HeartbeatManagerComponent( [ const component = new HeartbeatManagerComponent({ getHeartbeats: () => [ heartbeat("user", { source: "heartbeat" }), heartbeat("agent", { source: "rlm_heartbeat", status: "paused",1 unmodified line deliveryMode: "follow_up", lastError: "the previous delivery failed", }), ], { getRows: () => 20, onAction: async () => {}, onClose: () => {}, requestRender: () => {} }, ); getRows: () => 20, onAction: async () => {}, onClose: () => {}, requestRender: () => {}, }); for (const width of [32, 48, 80]) { const lines = component.render(width); expect(lines.every((line) => visibleWidth(line) === width)).toBe(true); }12 unmodified lines expect(titleColumn).toBeGreaterThan(2); expect(rendered.find((line) => line.includes("Esc close"))?.indexOf("Esc close")).toBe(titleColumn); });
it("reads the current heartbeat list when rendered", () => { let heartbeats = [heartbeat("user", { source: "heartbeat" })]; const component = new HeartbeatManagerComponent({ getHeartbeats: () => heartbeats, getRows: () => 20, onAction: async () => {}, onClose: () => {}, requestRender: () => {}, });
expect(stripAnsi(component.render(80).join("\n"))).toContain("1 heartbeat."); heartbeats = [heartbeat("user", { source: "heartbeat" }), heartbeat("agent", { source: "rlm_heartbeat" })]; expect(stripAnsi(component.render(80).join("\n"))).toContain("2 heartbeats."); });
it("uses arrows to open and go back, and closes with escape or the toggle shortcut", () => { let closeCount = 0; const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], { const component = new HeartbeatManagerComponent({ getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })], getRows: () => 20, onAction: async () => {}, onClose: () => closeCount++, requestRender: () => {},22 unmodified lines });
it("pauses and stops individual heartbeats immediately", async () => { const actions: Array<{ id: string; action: AgentHeartbeatManagementAction }> = []; const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], { const component = new HeartbeatManagerComponent({ getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })], getRows: () => 20, onAction: async (entry, action) => { actions.push({ id: entry.job.id, action }); },19 unmodified lines6 unmodified lines7891011111213148 unmodified lines23242526282728312930313233343536374038394041424344454647454849505152535423 unmodified lines787980817778828384858611 unmodified lines9899100101989910210310410510618 unmodified lines1251261271281261291301311325 unmodified lines1381391401411401421431441451461471481481491491501511521534 unmodified lines1581591601611621621631641651661671681691701711671721731741751761771781791751801811821831841851864 unmodified lines1911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472486 unmodified linesimport { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
interface HeartbeatManagementHarness { heartbeatCatalog: AgentConnectionHeartbeat[]; heartbeats: AgentConnectionHeartbeat[]; agentConnection: { manageHeartbeat( activeSessionId: string, jobId: string,8 unmodified lines}
interface HeartbeatScopeHarness { heartbeatCatalog: AgentConnectionHeartbeat[]; heartbeats: AgentConnectionHeartbeat[]; connectionState: { activeSessionId: string; sessionId: string }; subagentSnapshots: Map<string, AgentConnectionRlmChildAgentSnapshot>; heartbeatManager: { setHeartbeats(heartbeats: AgentConnectionHeartbeat[]): void } | undefined; ui: { requestRender(): void }; scheduleHeartbeatManagerRefresh(): void; updateSubagentSummaryLine(): void; applyHeartbeatCatalog(heartbeats: AgentConnectionHeartbeat[]): void; getScopedHeartbeats(): AgentConnectionHeartbeat[];}
interface ChildIdentityUpdateHarness { subagentSnapshots: Map<string, AgentConnectionRlmChildAgentSnapshot>; refreshSubagentSummary(): void; ui: { requestRender(): void }; updateSubagentSummary(child: AgentConnectionRlmChildAgentSnapshot): void; scheduleHeartbeatManagerRefresh(): void; updateSubagentSummaryLine(): void; updateWorkingPulse(): void; syncWorkingLoader(): void; updateWorkingLoaderMessage(): void;}
interface HeartbeatRefreshHarness { heartbeats: AgentConnectionHeartbeat[]; heartbeatCatalog: AgentConnectionHeartbeat[]; connectionState: { activeSessionId: string; sessionId: string }; subagentSnapshots: Map<string, AgentConnectionRlmChildAgentSnapshot>; heartbeatManager: object | undefined; heartbeatManagerRefreshTimer: ReturnType<typeof setTimeout> | undefined; refreshHeartbeatCatalog(): Promise<void>; scheduleHeartbeatManagerRefresh(): void;23 unmodified lines const current = heartbeat(); const stopped = { ...current, status: "cancelled" as const, nextRunAt: undefined }; const patches: Array<{ heartbeat: AgentCronJob | null }> = []; const harness = Object.create(InteractiveMode.prototype) as HeartbeatManagementHarness; harness.heartbeats = [{ job: current }]; harness.heartbeatCatalog = harness.heartbeats; harness.heartbeatCatalog = [{ job: current }]; harness.connectionState = { activeSessionId: current.activeSessionId }; harness.agentConnection = { manageHeartbeat: vi.fn(async () => stopped), };11 unmodified lines it("keeps a successful action successful when the catalog refresh fails", async () => { const current = heartbeat(); const paused = { ...current, status: "paused" as const, nextRunAt: undefined }; const harness = Object.create(InteractiveMode.prototype) as HeartbeatManagementHarness; harness.heartbeats = [{ job: current, sessionName: "Primary session" }]; harness.heartbeatCatalog = harness.heartbeats; harness.heartbeatCatalog = [{ job: current, sessionName: "Primary session" }]; harness.connectionState = { activeSessionId: current.activeSessionId }; harness.agentConnection = { manageHeartbeat: vi.fn(async () => paused) }; harness.patchConnectionState = vi.fn(); harness.applyHeartbeatCatalog = vi.fn();18 unmodified lines job: heartbeat({ id: "heartbeat-3", activeSessionId: "active-3", sessionId: "session-3" }), }; const harness = Object.create(InteractiveMode.prototype) as HeartbeatScopeHarness; harness.heartbeatCatalog = []; harness.heartbeats = []; harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; harness.subagentSnapshots = new Map([ [ "child-1",5 unmodified lines sessionDir: "/tmp/child-1", }, ], ]); harness.heartbeatManager = { setHeartbeats: vi.fn() }; harness.ui = { requestRender: vi.fn() }; harness.scheduleHeartbeatManagerRefresh = vi.fn(); harness.updateSubagentSummaryLine = vi.fn();
harness.applyHeartbeatCatalog([own, child, unrelated]);
expect(harness.heartbeatCatalog).toEqual([own, child, unrelated]); expect(harness.heartbeats).toEqual([own, child]); expect(harness.heartbeatManager.setHeartbeats).toHaveBeenCalledWith([own, child]); expect(harness.getScopedHeartbeats()).toEqual([own, child]); expect(harness.updateSubagentSummaryLine).toHaveBeenCalledOnce(); });
it("refreshes heartbeat scope when a known subagent gains its active session id", () => {4 unmodified lines sessionDir: "/tmp/child-1", }; const harness = Object.create(InteractiveMode.prototype) as ChildIdentityUpdateHarness; harness.subagentSnapshots = new Map([[existing.id, existing]]); harness.refreshSubagentSummary = vi.fn(); harness.ui = { requestRender: vi.fn() }; harness.scheduleHeartbeatManagerRefresh = vi.fn(); harness.updateSubagentSummaryLine = vi.fn(); harness.updateWorkingPulse = vi.fn(); harness.syncWorkingLoader = vi.fn(); harness.updateWorkingLoaderMessage = vi.fn();
harness.updateSubagentSummary({ ...existing, activeSessionId: "active-2" });
expect(harness.subagentSnapshots.get(existing.id)?.activeSessionId).toBe("active-2"); expect(harness.refreshSubagentSummary).toHaveBeenCalledOnce(); expect(harness.scheduleHeartbeatManagerRefresh).toHaveBeenCalledOnce(); });
it("refreshes an open manager after the next scheduled run", async () => { vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const harness = Object.create(InteractiveMode.prototype) as HeartbeatRefreshHarness; harness.heartbeats = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:01.000Z" } }]; harness.heartbeatCatalog = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:01.000Z" } }]; harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; harness.subagentSnapshots = new Map(); harness.heartbeatManager = {}; harness.heartbeatManagerRefreshTimer = undefined; harness.refreshHeartbeatCatalog = vi.fn(async () => {});
4 unmodified lines } finally { vi.useRealTimers(); } });
it("keeps the overdue refresh deadline when subagent updates re-derive the schedule", async () => { vi.useFakeTimers(); try { // nextRunAt (00:05) is already in the past, so the 5s overdue fallback applies. vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); const harness = Object.create(InteractiveMode.prototype) as HeartbeatRefreshHarness; harness.heartbeatCatalog = [{ job: heartbeat() }]; harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; harness.subagentSnapshots = new Map(); harness.heartbeatManager = {}; harness.heartbeatManagerRefreshTimer = undefined; harness.refreshHeartbeatCatalog = vi.fn(async () => {});
harness.scheduleHeartbeatManagerRefresh(); // Subagent snapshot updates re-derive the schedule more often than // every 5s; they must not postpone the pending overdue refresh. for (let i = 0; i < 5; i++) { await vi.advanceTimersByTimeAsync(1_000); harness.scheduleHeartbeatManagerRefresh(); }
expect(harness.refreshHeartbeatCatalog).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); } });
it("re-arms to an earlier deadline when a sooner heartbeat appears", async () => { vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const harness = Object.create(InteractiveMode.prototype) as HeartbeatRefreshHarness; // Two minutes out, so the first schedule arms the capped 60s poll. harness.heartbeatCatalog = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:02:00.000Z" } }]; harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; harness.subagentSnapshots = new Map(); harness.heartbeatManager = {}; harness.heartbeatManagerRefreshTimer = undefined; harness.refreshHeartbeatCatalog = vi.fn(async () => {});
harness.scheduleHeartbeatManagerRefresh(); // A sooner heartbeat must pull the pending refresh forward, not sit // behind the already-armed 60s poll. harness.heartbeatCatalog = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:02.000Z" } }]; harness.scheduleHeartbeatManagerRefresh(); await vi.advanceTimersByTimeAsync(3_000);
expect(harness.refreshHeartbeatCatalog).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); } });});4251 unmodified linesExpand all4252425342544255425642564257425842594260426142624263426421 unmodified lines428642874288428942874290429142924293429442954296429742989 unmodified lines430843094310431143064312431343144315431643174318431943209 unmodified lines4330433143324333432543344335433643374338433943404341434210 unmodified lines435343544355435643454357435843594360436143624363436443651189 unmodified linesExpand all4251 unmodified linesExpand all
describe("InteractiveMode tray goal label", () => { type TrayUsage = { contextWindow: number; tokens: number | null; percent: number | null }; type TrayLabelHarness = { heartbeats: AgentConnectionHeartbeat[]; heartbeatCatalog: AgentConnectionHeartbeat[]; subagentSnapshots: Map<string, never>; connectionState: { activeSessionId: string; sessionId: string; goal: GoalState; heartbeat?: AgentCronJob | null; contextUsage: TrayUsage | undefined; };21 unmodified lines }
test("shows active goals in the lower tray without an objective", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeats = []; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "a long objective that should not render in the tray",9 unmodified lines });
test("combines active goals with token/context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeats = []; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "finish the task",9 unmodified lines });
test("combines active goals, active heartbeats, and context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeats = [{ job: createHeartbeat("active") }]; fakeThis.heartbeatCatalog = [{ job: createHeartbeat("active") }]; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "finish the task",10 unmodified lines });
test("omits the usage segment when token count is unknown", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeats = []; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "finish the task",1189 unmodified linesExpand all131 unmodified linesExpand all13213313413513613613713813917 unmodified lines15715815916016216116216316420 unmodified lines18518618718819118919019119223 unmodified lines21621721821922322022122222334 unmodified lines131 unmodified linesExpand all subagentSnapshots: new Map<string, AgentConnectionRlmChildAgentSnapshot>(), rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), ui: { requestRender: vi.fn() },17 unmodified lines subagentSnapshots: new Map<string, AgentConnectionRlmChildAgentSnapshot>(), rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), ui: { requestRender: vi.fn() },20 unmodified lines subagentSnapshots: new Map<string, AgentConnectionRlmChildAgentSnapshot>(), rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), ui: { requestRender: vi.fn() },23 unmodified lines subagentSnapshots: new Map<string, AgentConnectionRlmChildAgentSnapshot>(), rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), ui: { requestRender: vi.fn() },34 unmodified lines