Files
fusion/packages/engine/src/agent-heartbeat.test.ts
Fusion 8a0885ad48 fix(FN-2392): normalize skill-facing tool references to fn_*
- Update Fusion skill docs, prompts, and capability references to use public fn_* tool names consistently
- Align engine system prompts and tool schemas for messaging/task actions with fn_send_message, fn_read_messages, fn_task_* naming
- Refresh related tests across CLI, engine, dashboard, and core to match normalized tool naming and behavior
- Add a patch changeset for @runfusion/fusion describing the skill-tool namespace normalization
2026-04-24 08:54:25 -07:00

5491 lines
213 KiB
TypeScript

// NOTE: This file uses mock stores/fakes instead of real SQLite databases.
// See FN-2142 for the rationale.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import {
HeartbeatMonitor,
HeartbeatTriggerScheduler,
isBlockedStateDuplicate,
type AgentSession,
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
} from "./agent-heartbeat.js";
import { AgentLogger } from "./agent-logger.js";
import * as agentTools from "./agent-tools.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
// Mock logger to suppress noise in test output
vi.mock("./logger.js", () => {
const createMockLogger = () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
});
return {
createLogger: vi.fn(() => createMockLogger()),
heartbeatLog: createMockLogger(),
};
});
// Mock pi.ts for executeHeartbeat tests
vi.mock("./pi.js", () => ({
createFnAgent: vi.fn(),
promptWithFallback: vi.fn(async (session: any, prompt: string) => {
await session.prompt(prompt);
}),
}));
// Import the mocked functions for test control
import { createFnAgent } from "./pi.js";
import { heartbeatLog } from "./logger.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
// Mock store factory
function createMockStore(overrides: Partial<AgentStore> = {}): AgentStore {
return {
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as AgentStore;
}
// Mock session factory
function createMockSession(): AgentSession {
return {
dispose: vi.fn(),
};
}
function createMockMessageStore(onSetHook?: (hook: (message: Message) => void) => void): MessageStore {
return {
setMessageToAgentHook: vi.fn((hook: (message: Message) => void) => {
onSetHook?.(hook);
}),
} as unknown as MessageStore;
}
function createMessage(overrides: Partial<Message> = {}): Message {
const now = new Date().toISOString();
return {
id: "msg-001",
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "hello",
type: "user-to-agent",
read: false,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function createBudgetStatus(overrides: Partial<AgentBudgetStatus> = {}): AgentBudgetStatus {
return {
agentId: "agent-001",
currentUsage: 0,
budgetLimit: null,
usagePercent: null,
thresholdPercent: null,
isOverBudget: false,
isOverThreshold: false,
lastResetAt: null,
nextResetAt: null,
...overrides,
};
}
describe("HeartbeatMonitor", () => {
let store: AgentStore;
let monitor: HeartbeatMonitor;
beforeEach(() => {
store = createMockStore();
monitor = new HeartbeatMonitor({ store });
});
afterEach(() => {
monitor.stop();
vi.useRealTimers();
});
describe("constructor", () => {
it("initializes with default options", () => {
expect(monitor).toBeDefined();
expect(monitor.isActive()).toBe(false);
});
it("accepts custom pollIntervalMs", () => {
const customMonitor = new HeartbeatMonitor({ store, pollIntervalMs: 5000 });
expect(customMonitor).toBeDefined();
});
it("accepts custom heartbeatTimeoutMs", () => {
const customMonitor = new HeartbeatMonitor({ store, heartbeatTimeoutMs: 120000 });
expect(customMonitor).toBeDefined();
});
it("accepts callbacks", () => {
const onMissed = vi.fn();
const onRecovered = vi.fn();
const onTerminated = vi.fn();
const customMonitor = new HeartbeatMonitor({
store,
onMissed,
onRecovered,
onTerminated,
});
expect(customMonitor).toBeDefined();
});
});
describe("isBlockedStateDuplicate", () => {
it("returns true when blockedBy and contextHash match", () => {
expect(
isBlockedStateDuplicate(
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-01T00:00:00.000Z", contextHash: "abc" },
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-02T00:00:00.000Z", contextHash: "abc" },
),
).toBe(true);
});
it("returns false when blockedBy differs or contextHash differs", () => {
expect(
isBlockedStateDuplicate(
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-01T00:00:00.000Z", contextHash: "abc" },
{ taskId: "FN-1", blockedBy: "FN-2", recordedAt: "2026-01-02T00:00:00.000Z", contextHash: "abc" },
),
).toBe(false);
expect(
isBlockedStateDuplicate(
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-01T00:00:00.000Z", contextHash: "abc" },
{ taskId: "FN-1", blockedBy: "FN-0", recordedAt: "2026-01-02T00:00:00.000Z", contextHash: "xyz" },
),
).toBe(false);
});
});
describe("start", () => {
it("initiates polling interval", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
monitor.start();
expect(monitor.isActive()).toBe(true);
vi.useRealTimers();
});
it("is idempotent (multiple calls don't create multiple intervals)", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
monitor.start();
monitor.start();
monitor.start();
expect(monitor.isActive()).toBe(true);
// Stop should clean up properly
monitor.stop();
expect(monitor.isActive()).toBe(false);
vi.useRealTimers();
});
});
describe("stop", () => {
it("clears the polling interval", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
monitor.start();
expect(monitor.isActive()).toBe(true);
monitor.stop();
expect(monitor.isActive()).toBe(false);
vi.useRealTimers();
});
it("is safe to call when not started", () => {
expect(() => monitor.stop()).not.toThrow();
expect(monitor.isActive()).toBe(false);
});
it("is safe to call multiple times", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
monitor.start();
monitor.stop();
monitor.stop();
monitor.stop();
expect(monitor.isActive()).toBe(false);
vi.useRealTimers();
});
});
describe("wake-on-message", () => {
it("executes heartbeat when messageResponseMode is immediate", () => {
let messageHook: ((message: Message) => void) | undefined;
const messageStore = createMockMessageStore((hook) => {
messageHook = hook;
});
const configStore = createMockStore({
getCachedAgent: vi.fn().mockReturnValue({
id: "agent-1",
state: "active",
runtimeConfig: { messageResponseMode: "immediate" },
}),
});
const customMonitor = new HeartbeatMonitor({
store,
agentStore: configStore,
messageStore,
});
const executeHeartbeatSpy = vi
.spyOn(customMonitor, "executeHeartbeat")
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
customMonitor.start();
messageHook?.(createMessage({ toId: "agent-1", toType: "agent" }));
expect(executeHeartbeatSpy).toHaveBeenCalledWith({
agentId: "agent-1",
source: "on_demand",
triggerDetail: "wake-on-message",
});
customMonitor.stop();
});
it("does not execute heartbeat when messageResponseMode is on-heartbeat or unset", () => {
let messageHook: ((message: Message) => void) | undefined;
const messageStore = createMockMessageStore((hook) => {
messageHook = hook;
});
const getCachedAgent = vi
.fn()
.mockReturnValueOnce({
id: "agent-1",
state: "active",
runtimeConfig: { messageResponseMode: "on-heartbeat" },
})
.mockReturnValueOnce({
id: "agent-1",
state: "active",
runtimeConfig: {},
});
const configStore = createMockStore({ getCachedAgent });
const customMonitor = new HeartbeatMonitor({
store,
agentStore: configStore,
messageStore,
});
const executeHeartbeatSpy = vi
.spyOn(customMonitor, "executeHeartbeat")
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
customMonitor.start();
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-1" }));
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-2" }));
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
customMonitor.stop();
});
it("does not execute heartbeat when agent is paused or error", () => {
let messageHook: ((message: Message) => void) | undefined;
const messageStore = createMockMessageStore((hook) => {
messageHook = hook;
});
const getCachedAgent = vi
.fn()
.mockReturnValueOnce({
id: "agent-1",
state: "paused",
runtimeConfig: { messageResponseMode: "immediate" },
})
.mockReturnValueOnce({
id: "agent-1",
state: "error",
runtimeConfig: { messageResponseMode: "immediate" },
});
const configStore = createMockStore({ getCachedAgent });
const customMonitor = new HeartbeatMonitor({
store,
agentStore: configStore,
messageStore,
});
const executeHeartbeatSpy = vi
.spyOn(customMonitor, "executeHeartbeat")
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
customMonitor.start();
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-paused" }));
messageHook?.(createMessage({ toId: "agent-1", toType: "agent", id: "msg-error" }));
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
customMonitor.stop();
});
it("registers the message hook on start and clears it on stop", () => {
const hooks: Array<(message: Message) => void> = [];
const messageStore = createMockMessageStore((hook) => {
hooks.push(hook);
});
const customMonitor = new HeartbeatMonitor({ store, messageStore });
customMonitor.start();
expect(messageStore.setMessageToAgentHook).toHaveBeenCalledTimes(1);
expect(hooks).toHaveLength(1);
customMonitor.stop();
expect(messageStore.setMessageToAgentHook).toHaveBeenCalledTimes(2);
expect(hooks).toHaveLength(2);
expect(hooks[0]).not.toBe(hooks[1]);
});
it("ignores non-agent messages", () => {
let messageHook: ((message: Message) => void) | undefined;
const messageStore = createMockMessageStore((hook) => {
messageHook = hook;
});
const configStore = createMockStore({
getCachedAgent: vi.fn().mockReturnValue({
id: "agent-1",
state: "active",
runtimeConfig: { messageResponseMode: "immediate" },
}),
});
const customMonitor = new HeartbeatMonitor({
store,
agentStore: configStore,
messageStore,
});
const executeHeartbeatSpy = vi
.spyOn(customMonitor, "executeHeartbeat")
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
customMonitor.start();
messageHook?.(createMessage({ toType: "user", toId: "user-1" }));
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
customMonitor.stop();
});
describe("createHeartbeatTools - message tools", () => {
let mockTaskStore: TaskStore;
let mockSession: ReturnType<typeof createMockSession>;
let capturedTools: any[] = [];
beforeEach(() => {
mockTaskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-002", description: "test", dependencies: [], column: "triage" }),
logEntry: vi.fn().mockResolvedValue(undefined),
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1", taskId: "FN-001", key: "test", content: "test", revision: 1, author: "agent",
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocuments: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
mockSession = createMockSession();
capturedTools = [];
});
it("includes fn_send_message and fn_read_messages tools when messageStore is available", () => {
const messageStore = createMockMessageStore();
const customMonitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const tools = customMonitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001", undefined, undefined, messageStore);
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("fn_send_message");
expect(toolNames).toContain("fn_read_messages");
});
it("does not include message tools when messageStore is not provided", () => {
const customMonitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const tools = customMonitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
const toolNames = tools.map((t) => t.name);
expect(toolNames).not.toContain("fn_send_message");
expect(toolNames).not.toContain("fn_read_messages");
});
it("does not include message tools when messageStore is undefined even if other params are passed", () => {
const customMonitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const tools = customMonitor.createHeartbeatTools(
"agent-001",
mockTaskStore,
"FN-001",
undefined,
undefined,
undefined
);
const toolNames = tools.map((t) => t.name);
expect(toolNames).not.toContain("fn_send_message");
expect(toolNames).not.toContain("fn_read_messages");
});
});
});
describe("isActive", () => {
it("reflects monitor state (false when not started)", () => {
expect(monitor.isActive()).toBe(false);
});
it("reflects monitor state (true when started)", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
monitor.start();
expect(monitor.isActive()).toBe(true);
vi.useRealTimers();
});
it("reflects monitor state (false after stopped)", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
monitor.start();
monitor.stop();
expect(monitor.isActive()).toBe(false);
vi.useRealTimers();
});
});
describe("trackAgent", () => {
it("adds agent to tracked set with correct initial state", () => {
const session = createMockSession();
const before = Date.now();
monitor.trackAgent("agent-001", session, "run-001");
const lastSeen = monitor.getLastSeen("agent-001");
expect(lastSeen).toBeDefined();
expect(lastSeen).toBeGreaterThanOrEqual(before);
expect(monitor.getTrackedAgents()).toContain("agent-001");
});
it("records initial heartbeat to store", () => {
const session = createMockSession();
monitor.trackAgent("agent-001", session, "run-001");
expect(store.recordHeartbeat).toHaveBeenCalledWith("agent-001", "ok", "run-001");
});
it("can track multiple agents", () => {
monitor.trackAgent("agent-001", createMockSession(), "run-001");
monitor.trackAgent("agent-002", createMockSession(), "run-002");
monitor.trackAgent("agent-003", createMockSession(), "run-003");
expect(monitor.getTrackedAgents()).toHaveLength(3);
expect(monitor.getTrackedAgents()).toContain("agent-001");
expect(monitor.getTrackedAgents()).toContain("agent-002");
expect(monitor.getTrackedAgents()).toContain("agent-003");
});
});
describe("recordHeartbeat", () => {
it("updates lastSeen timestamp", () => {
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
monitor.trackAgent("agent-001", session, "run-001");
const initialLastSeen = monitor.getLastSeen("agent-001")!;
vi.advanceTimersByTime(100);
monitor.recordHeartbeat("agent-001");
const newLastSeen = monitor.getLastSeen("agent-001")!;
expect(newLastSeen).toBeGreaterThan(initialLastSeen);
vi.useRealTimers();
});
it("records ok heartbeat to store", () => {
const session = createMockSession();
monitor.trackAgent("agent-001", session, "run-001");
monitor.recordHeartbeat("agent-001");
// Should have been called twice: once on track, once on heartbeat
expect(store.recordHeartbeat).toHaveBeenCalledTimes(2);
expect(store.recordHeartbeat).toHaveBeenLastCalledWith("agent-001", "ok", "run-001");
});
it("triggers onRecovered callback after missed heartbeat", () => {
const onRecovered = vi.fn();
const customMonitor = new HeartbeatMonitor({ store, onRecovered });
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.trackAgent("agent-001", session, "run-001");
// Simulate missed heartbeat by advancing time
vi.advanceTimersByTime(70000); // Default timeout is 60000
// Trigger the check
customMonitor.stop();
// Reset and record heartbeat (should trigger recovery)
customMonitor.recordHeartbeat("agent-001");
expect(onRecovered).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("does nothing for untracked agent", () => {
expect(() => monitor.recordHeartbeat("agent-001")).not.toThrow();
expect(store.recordHeartbeat).not.toHaveBeenCalled();
});
});
describe("isAgentHealthy", () => {
it("returns true for recent heartbeat", () => {
const session = createMockSession();
monitor.trackAgent("agent-001", session, "run-001");
expect(monitor.isAgentHealthy("agent-001")).toBe(true);
});
it("returns false for missed heartbeat", () => {
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
// Use short timeout for testing
const customMonitor = new HeartbeatMonitor({
store,
heartbeatTimeoutMs: 5000,
});
customMonitor.trackAgent("agent-001", session, "run-001");
expect(customMonitor.isAgentHealthy("agent-001")).toBe(true);
// Advance past timeout
vi.advanceTimersByTime(6000);
expect(customMonitor.isAgentHealthy("agent-001")).toBe(false);
vi.useRealTimers();
});
it("returns false for untracked agent", () => {
expect(monitor.isAgentHealthy("agent-001")).toBe(false);
});
});
describe("getTrackedAgents", () => {
it("returns empty array when no agents tracked", () => {
expect(monitor.getTrackedAgents()).toEqual([]);
});
it("returns all tracked agent IDs", () => {
monitor.trackAgent("agent-001", createMockSession(), "run-001");
monitor.trackAgent("agent-002", createMockSession(), "run-002");
const agents = monitor.getTrackedAgents();
expect(agents).toHaveLength(2);
expect(agents).toContain("agent-001");
expect(agents).toContain("agent-002");
});
});
describe("getLastSeen", () => {
it("returns correct timestamp for tracked agent", () => {
const session = createMockSession();
const before = Date.now();
monitor.trackAgent("agent-001", session, "run-001");
const lastSeen = monitor.getLastSeen("agent-001");
expect(lastSeen).toBeDefined();
expect(lastSeen).toBeGreaterThanOrEqual(before);
});
it("returns undefined for untracked agent", () => {
expect(monitor.getLastSeen("agent-001")).toBeUndefined();
});
});
describe("missed heartbeat detection", () => {
it("triggers onMissed callback when heartbeat is missed", async () => {
const onMissed = vi.fn();
const customMonitor = new HeartbeatMonitor({
store,
heartbeatTimeoutMs: 5000,
pollIntervalMs: 1000,
onMissed,
});
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.start();
customMonitor.trackAgent("agent-001", session, "run-001");
// Wait for polling to detect missed heartbeat
vi.advanceTimersByTime(6000);
// Wait for async checkMissedHeartbeats
await vi.advanceTimersByTimeAsync(100);
expect(onMissed).toHaveBeenCalledWith("agent-001");
customMonitor.stop();
vi.useRealTimers();
});
it("records missed heartbeat to store", async () => {
const customMonitor = new HeartbeatMonitor({
store,
heartbeatTimeoutMs: 5000,
pollIntervalMs: 1000,
});
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.start();
customMonitor.trackAgent("agent-001", session, "run-001");
// Wait for polling to detect missed heartbeat
vi.advanceTimersByTime(6000);
await vi.advanceTimersByTimeAsync(100);
expect(store.recordHeartbeat).toHaveBeenCalledWith("agent-001", "missed", "run-001");
customMonitor.stop();
vi.useRealTimers();
});
});
describe("unresponsive agent termination", () => {
it("disposes session and terminates agent after 2x timeout", async () => {
const onTerminated = vi.fn();
const session = createMockSession();
const customMonitor = new HeartbeatMonitor({
store,
heartbeatTimeoutMs: 5000,
pollIntervalMs: 1000,
onTerminated,
});
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.start();
customMonitor.trackAgent("agent-001", session, "run-001");
// Wait for missed heartbeat (1x timeout)
vi.advanceTimersByTime(6000);
await vi.advanceTimersByTimeAsync(100);
// Wait for termination (2x timeout = 10 seconds total from start)
vi.advanceTimersByTime(6000);
await vi.advanceTimersByTimeAsync(100);
expect(session.dispose).toHaveBeenCalled();
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
expect(onTerminated).toHaveBeenCalledWith("agent-001");
customMonitor.stop();
vi.useRealTimers();
});
it("removes agent from tracking after termination", async () => {
const session = createMockSession();
const customMonitor = new HeartbeatMonitor({
store,
heartbeatTimeoutMs: 5000,
pollIntervalMs: 1000,
});
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.start();
customMonitor.trackAgent("agent-001", session, "run-001");
expect(customMonitor.getTrackedAgents()).toContain("agent-001");
// Wait for termination
vi.advanceTimersByTime(12000);
await vi.advanceTimersByTimeAsync(100);
expect(customMonitor.getTrackedAgents()).not.toContain("agent-001");
customMonitor.stop();
vi.useRealTimers();
});
it("logs warning when session dispose throws during termination", async () => {
const warnSpy = vi.mocked(heartbeatLog.warn);
warnSpy.mockClear();
const session: AgentSession = {
dispose: vi.fn(() => {
throw new Error("dispose exploded");
}),
};
const updateAgentState = vi.fn().mockResolvedValue(undefined);
const localStore = createMockStore({ updateAgentState });
const onTerminated = vi.fn();
const customMonitor = new HeartbeatMonitor({
store: localStore,
heartbeatTimeoutMs: 5000,
pollIntervalMs: 1000,
onTerminated,
});
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.start();
customMonitor.trackAgent("agent-001", session, "run-001");
vi.advanceTimersByTime(10100);
await vi.advanceTimersByTimeAsync(100);
const warnMessages = warnSpy.mock.calls.map(([message]) => String(message));
expect(warnMessages.some((message) => message.includes("Error disposing session for agent-001") && message.includes("dispose exploded"))).toBe(true);
expect(updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
expect(onTerminated).toHaveBeenCalledWith("agent-001");
expect(customMonitor.getTrackedAgents()).toHaveLength(0);
customMonitor.stop();
vi.useRealTimers();
});
it("logs warning when updateAgentState throws during termination", async () => {
const warnSpy = vi.mocked(heartbeatLog.warn);
warnSpy.mockClear();
const session = createMockSession();
const localStore = createMockStore({
updateAgentState: vi.fn().mockRejectedValue(new Error("db connection lost")),
});
const onTerminated = vi.fn();
const customMonitor = new HeartbeatMonitor({
store: localStore,
heartbeatTimeoutMs: 5000,
pollIntervalMs: 1000,
onTerminated,
});
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.start();
customMonitor.trackAgent("agent-001", session, "run-001");
vi.advanceTimersByTime(10100);
await vi.advanceTimersByTimeAsync(100);
const warnMessages = warnSpy.mock.calls.map(([message]) => String(message));
expect(warnMessages.some((message) => message.includes("Error terminating agent agent-001") && message.includes("db connection lost"))).toBe(true);
expect(onTerminated).toHaveBeenCalledWith("agent-001");
expect(customMonitor.getTrackedAgents()).toHaveLength(0);
customMonitor.stop();
vi.useRealTimers();
});
it("logs warnings from both dispose and state update when both fail", async () => {
const warnSpy = vi.mocked(heartbeatLog.warn);
warnSpy.mockClear();
const session: AgentSession = {
dispose: vi.fn(() => {
throw new Error("dispose exploded");
}),
};
const localStore = createMockStore({
updateAgentState: vi.fn().mockRejectedValue(new Error("db connection lost")),
});
const onTerminated = vi.fn();
const customMonitor = new HeartbeatMonitor({
store: localStore,
heartbeatTimeoutMs: 5000,
pollIntervalMs: 1000,
onTerminated,
});
vi.useFakeTimers({ shouldAdvanceTime: true });
customMonitor.start();
customMonitor.trackAgent("agent-001", session, "run-001");
vi.advanceTimersByTime(10100);
await vi.advanceTimersByTimeAsync(100);
const warnMessages = warnSpy.mock.calls.map(([message]) => String(message));
expect(warnMessages).toHaveLength(2);
expect(warnMessages.some((message) => message.includes("Error disposing session for agent-001") && message.includes("dispose exploded"))).toBe(true);
expect(warnMessages.some((message) => message.includes("Error terminating agent agent-001") && message.includes("db connection lost"))).toBe(true);
expect(onTerminated).toHaveBeenCalledWith("agent-001");
expect(customMonitor.getTrackedAgents()).toHaveLength(0);
customMonitor.stop();
vi.useRealTimers();
});
});
describe("untrackAgent", () => {
it("removes agent from tracking", () => {
const session = createMockSession();
monitor.trackAgent("agent-001", session, "run-001");
expect(monitor.getTrackedAgents()).toContain("agent-001");
monitor.untrackAgent("agent-001");
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
expect(monitor.getTrackedAgents()).toHaveLength(0);
});
it("is safe to call for untracked agent", () => {
expect(() => monitor.untrackAgent("agent-001")).not.toThrow();
});
});
// ── Per-Agent Config Tests ──────────────────────────────────────────────
describe("per-agent heartbeat config", () => {
/** Create a mock store that returns a specific agent from getCachedAgent */
function createStoreWithAgent(agent: { id: string; runtimeConfig?: Record<string, unknown> }): AgentStore {
return {
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
getCachedAgent: vi.fn().mockReturnValue(agent),
} as unknown as AgentStore;
}
describe("getAgentHeartbeatConfig", () => {
it("returns monitor defaults when agentStore is not provided", async () => {
const monitor = new HeartbeatMonitor({
store,
pollIntervalMs: 5000,
heartbeatTimeoutMs: 10000,
maxConcurrentRuns: 2,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(5000);
expect(config.heartbeatTimeoutMs).toBe(10000);
expect(config.maxConcurrentRuns).toBe(2);
});
it("returns monitor defaults when agent has no runtimeConfig", async () => {
const agentStore = createStoreWithAgent({ id: "agent-001" });
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 5000,
heartbeatTimeoutMs: 10000,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(5000);
expect(config.heartbeatTimeoutMs).toBe(10000);
});
it("returns per-agent values when runtimeConfig is set", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: {
heartbeatIntervalMs: 2000,
heartbeatTimeoutMs: 30000,
maxConcurrentRuns: 3,
},
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 5000,
heartbeatTimeoutMs: 10000,
maxConcurrentRuns: 1,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(2000);
expect(config.heartbeatTimeoutMs).toBe(30000);
expect(config.maxConcurrentRuns).toBe(3);
});
it("clamps heartbeatIntervalMs to minimum of 1000", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatIntervalMs: 100 },
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 5000,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(1000);
});
it("clamps heartbeatTimeoutMs to minimum of 5000", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatTimeoutMs: 1000 },
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
heartbeatTimeoutMs: 60000,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.heartbeatTimeoutMs).toBe(5000);
});
it("clamps maxConcurrentRuns to minimum of 1", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { maxConcurrentRuns: 0 },
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
maxConcurrentRuns: 1,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.maxConcurrentRuns).toBe(1);
});
it("falls back to monitor defaults when runtimeConfig values are NaN", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: {
heartbeatIntervalMs: NaN,
heartbeatTimeoutMs: "not a number" as any,
},
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 5000,
heartbeatTimeoutMs: 10000,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(5000);
expect(config.heartbeatTimeoutMs).toBe(10000);
});
it("falls back to monitor defaults when agent is not found", async () => {
const agentStore = createStoreWithAgent({ id: "agent-001" });
(agentStore.getCachedAgent as ReturnType<typeof vi.fn>).mockReturnValue(null);
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 5000,
heartbeatTimeoutMs: 10000,
});
const config = await monitor.getAgentHeartbeatConfig("agent-999");
expect(config.pollIntervalMs).toBe(5000);
expect(config.heartbeatTimeoutMs).toBe(10000);
});
it("returns monitor defaults when getCachedAgent throws", async () => {
const agentStore = createStoreWithAgent({ id: "agent-001" });
(agentStore.getCachedAgent as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("Read error");
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 5000,
heartbeatTimeoutMs: 10000,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(5000);
expect(config.heartbeatTimeoutMs).toBe(10000);
});
it("returns partial overrides when only some runtimeConfig keys are set", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatTimeoutMs: 120000 },
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 5000,
heartbeatTimeoutMs: 60000,
maxConcurrentRuns: 1,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(5000); // fallback
expect(config.heartbeatTimeoutMs).toBe(120000); // overridden
expect(config.maxConcurrentRuns).toBe(1); // fallback
});
it("applies project heartbeatMultiplier to pollIntervalMs", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatIntervalMs: 60_000 },
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
taskStore: {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.5 }),
} as unknown as TaskStore,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(30_000);
});
it("clamps multiplied pollIntervalMs to minimum 1000ms", async () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatIntervalMs: 2000 },
});
const monitor = new HeartbeatMonitor({
store,
agentStore,
taskStore: {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.1 }),
} as unknown as TaskStore,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(1000);
});
});
describe("isAgentHealthy with per-agent config", () => {
it("uses per-agent timeout for health check", () => {
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatTimeoutMs: 30000 },
});
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
const monitor = new HeartbeatMonitor({
store,
agentStore,
heartbeatTimeoutMs: 5000, // Global default is 5000
});
monitor.trackAgent("agent-001", session, "run-001");
// Advance 10s — past the global 5s default, but within the per-agent 30s
vi.advanceTimersByTime(10000);
expect(monitor.isAgentHealthy("agent-001")).toBe(true);
// Advance past per-agent 30s timeout
vi.advanceTimersByTime(25000);
expect(monitor.isAgentHealthy("agent-001")).toBe(false);
vi.useRealTimers();
});
});
describe("checkMissedHeartbeats with per-agent config", () => {
it("detects missed heartbeat using per-agent timeout", async () => {
const onMissed = vi.fn();
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatTimeoutMs: 10000 },
});
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 1000,
heartbeatTimeoutMs: 5000, // Global default 5s — agent overrides to 10s
onMissed,
});
monitor.start();
monitor.trackAgent("agent-001", session, "run-001");
// Advance 6s — past global 5s but within per-agent 10s
vi.advanceTimersByTime(6000);
await vi.advanceTimersByTimeAsync(100);
// Should NOT have triggered onMissed because per-agent timeout is 10s
expect(onMissed).not.toHaveBeenCalled();
// Advance past the 10s per-agent timeout
vi.advanceTimersByTime(5000);
await vi.advanceTimersByTimeAsync(100);
expect(onMissed).toHaveBeenCalledWith("agent-001");
monitor.stop();
vi.useRealTimers();
});
it("terminates unresponsive agent using per-agent timeout", async () => {
const onTerminated = vi.fn();
const agentStore = createStoreWithAgent({
id: "agent-001",
runtimeConfig: { heartbeatTimeoutMs: 5000 },
});
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
const monitor = new HeartbeatMonitor({
store,
agentStore,
pollIntervalMs: 1000,
heartbeatTimeoutMs: 60000, // Global default 60s — agent overrides to 5s
onTerminated,
});
monitor.start();
monitor.trackAgent("agent-001", session, "run-001");
// Wait for missed (5s) + termination at 2x timeout (10s)
vi.advanceTimersByTime(12000);
await vi.advanceTimersByTimeAsync(100);
expect(session.dispose).toHaveBeenCalled();
expect(onTerminated).toHaveBeenCalledWith("agent-001");
monitor.stop();
vi.useRealTimers();
});
});
describe("backward compatibility", () => {
it("works without agentStore (no per-agent config)", async () => {
const monitor = new HeartbeatMonitor({
store,
heartbeatTimeoutMs: 5000,
});
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.heartbeatTimeoutMs).toBe(5000);
expect(config.pollIntervalMs).toBe(3_600_000); // default
expect(config.maxConcurrentRuns).toBe(1); // default
});
it("existing isAgentHealthy works without per-agent config", () => {
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
const monitor = new HeartbeatMonitor({
store,
heartbeatTimeoutMs: 5000,
});
monitor.trackAgent("agent-001", session, "run-001");
expect(monitor.isAgentHealthy("agent-001")).toBe(true);
vi.advanceTimersByTime(6000);
expect(monitor.isAgentHealthy("agent-001")).toBe(false);
vi.useRealTimers();
});
});
});
// ── Heartbeat Execution Tests ──────────────────────────────────────────
describe("executeHeartbeat", () => {
let mockTaskStore: TaskStore;
let mockAgent: Agent;
// Helper: create a mock session returned by createFnAgent
function createMockAgentSession() {
return {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
model: { provider: "mock", id: "mock-model" },
};
}
type MockTaskStoreOverrides = Partial<TaskStore> & {
checkoutTask?: (taskId: string, agentId: string) => Promise<unknown>;
};
// Helper: create a basic mock task store
function createMockTaskStore(overrides: MockTaskStoreOverrides = {}): TaskStore {
return {
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "# Test PROMPT.md\nSome content",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "Created task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
addComment: vi.fn().mockResolvedValue({}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
// Document-related methods for task_document tools
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocuments: vi.fn().mockResolvedValue([]),
...overrides,
} as unknown as TaskStore;
}
// Helper: create a mock store that returns a specific agent
function createStoreWithAgentForExec(agentData: Partial<Agent> = {}): AgentStore {
mockAgent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
...agentData,
} as Agent;
// Track saved runs so getRunDetail returns the most recent state
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
return {
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn().mockResolvedValue(mockAgent),
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
mockAgent.taskId = taskId;
return mockAgent;
}),
startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun),
saveRun: vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
}),
getRunDetail: vi.fn().mockImplementation(async (_agentId: string, runId: string) => {
return savedRuns.get(runId) ?? {
id: runId,
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "completed" as const,
};
}),
getRatingSummary: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),
getLastBlockedState: vi.fn().mockResolvedValue(null),
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentStore;
}
beforeEach(() => {
mockTaskStore = createMockTaskStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("dependency validation", () => {
it("throws when taskStore is not configured", async () => {
const store = createStoreWithAgentForExec();
const monitor = new HeartbeatMonitor({ store, rootDir: "/tmp" });
await expect(
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" })
).rejects.toThrow("HeartbeatMonitor not configured for execution (missing taskStore/rootDir)");
});
it("throws when rootDir is not configured", async () => {
const store = createStoreWithAgentForExec();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore });
await expect(
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" })
).rejects.toThrow("HeartbeatMonitor not configured for execution (missing taskStore/rootDir)");
});
});
describe("graceful exit", () => {
it("completes with no_assignment when agent has no taskId and no explicit taskId", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(result.resultJson).toEqual({ reason: "no_assignment" });
// Should NOT have created an agent session
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("completes with invalid_state when agent state is terminated", async () => {
const store = createStoreWithAgentForExec({ state: "terminated" });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(result.resultJson).toEqual({ reason: "invalid_state", state: "terminated" });
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
});
it("completes as failed when agent not found in store", async () => {
const store = createStoreWithAgentForExec();
(store.getAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("failed");
expect(result.stderrExcerpt).toContain("not found");
});
it("completes with task_not_found when task does not exist", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-MISSING" });
mockTaskStore.getTask = vi.fn().mockRejectedValue(new Error("Task FN-MISSING not found"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(result.resultJson).toEqual({ reason: "task_not_found", taskId: "FN-MISSING" });
});
});
// ── Identity Agents Without Tasks ─────────────────────────────────────────────
// FN-2051: Agents with identity (soul, instructions, memory) should run heartbeat
// sessions even without a task assignment, enabling them to do ambient work like
// messaging, memory management, task creation, and delegation.
describe("identity agents without tasks", () => {
it("agent WITH soul but no task creates session and completes successfully", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator agent who monitors project health" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
// Should create a session
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
// Reason should indicate identity run
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
});
it("agent WITH instructionsText but no task creates session and completes successfully", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
});
it("agent WITH memory but no task creates session and completes successfully", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, memory: "Last week we shipped the new API. Watch for integration issues." });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
});
it("ephemeral agent with soul but no task still bails with no_assignment", async () => {
// Ephemeral agents (agentKind: "task-worker") should NOT run no-task sessions
const store = createStoreWithAgentForExec({
taskId: undefined,
soul: "I am a task worker",
metadata: { agentKind: "task-worker" },
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
// Ephemeral agents should NOT create a session
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
// Should still exit with no_assignment (not no_assignment_identity_run)
expect(result.resultJson).toEqual({ reason: "no_assignment" });
});
it("identity agent without task receives correct tools (fn_task_create, fn_list_agents, fn_delegate_task, fn_heartbeat_done)", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
// Should have fn_task_create, fn_list_agents, fn_delegate_task
expect(toolNames).toContain("fn_task_create");
expect(toolNames).toContain("fn_list_agents");
expect(toolNames).toContain("fn_delegate_task");
// Should have fn_heartbeat_done
expect(toolNames).toContain("fn_heartbeat_done");
// Should have memory tools
expect(toolNames).toContain("fn_memory_search");
expect(toolNames).toContain("fn_memory_append");
// Should NOT have fn_task_log, fn_task_document_write, fn_task_document_read (they require taskId)
expect(toolNames).not.toContain("fn_task_log");
expect(toolNames).not.toContain("fn_task_document_write");
expect(toolNames).not.toContain("fn_task_document_read");
});
it("no-task run receives HEARTBEAT_NO_TASK_SYSTEM_PROMPT as system prompt", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const systemPrompt = callArgs.systemPrompt;
expect(systemPrompt).toContain(HEARTBEAT_NO_TASK_SYSTEM_PROMPT);
expect(systemPrompt).not.toContain("fn_task_log");
expect(systemPrompt).not.toContain("fn_task_document_write");
expect(systemPrompt).not.toContain("fn_task_document_read");
expect(systemPrompt).toContain("fn_task_create");
expect(systemPrompt).toContain("fn_list_agents");
expect(systemPrompt).toContain("fn_delegate_task");
expect(systemPrompt).toContain("fn_read_messages");
expect(systemPrompt).toContain("fn_send_message");
expect(systemPrompt).toContain("fn_memory_search");
expect(systemPrompt).toContain("fn_memory_append");
expect(systemPrompt).toContain("fn_heartbeat_done");
});
it("identity agent without task receives no-task execution prompt mentioning 'no assigned task'", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const systemPrompt = callArgs.systemPrompt;
expect(systemPrompt).toContain(HEARTBEAT_NO_TASK_SYSTEM_PROMPT);
expect(systemPrompt).not.toContain("fn_task_log");
expect(systemPrompt).not.toContain("fn_task_document_write");
expect(systemPrompt).not.toContain("fn_task_document_read");
expect(systemPrompt).not.toContain("Task Documents:");
expect(systemPrompt).toContain("fn_task_create");
expect(systemPrompt).toContain("fn_heartbeat_done");
expect(systemPrompt).toContain("fn_memory_append");
// The execution prompt is passed to session.prompt by promptWithFallback mock
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1]![0]!;
// Should mention no assigned task
expect(executionPrompt).toContain("No assigned task");
// Should describe ambient work capabilities
expect(executionPrompt).toContain("ambient work");
expect(executionPrompt).toContain("fn_task_create");
expect(executionPrompt).toContain("fn_list_agents");
expect(executionPrompt).toContain("fn_delegate_task");
// Should NOT include task-specific content
expect(executionPrompt).not.toContain("Assigned task:");
expect(executionPrompt).not.toContain("Task description:");
});
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const systemPrompt = callArgs.systemPrompt;
expect(systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(systemPrompt).toContain("fn_task_log");
expect(systemPrompt).toContain("fn_task_document_write");
expect(systemPrompt).toContain("Task Documents:");
});
it("identity agent without task gets soul in system prompt", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a CEO who prioritizes high-impact work" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
// Soul should be in the system prompt
expect(callArgs.systemPrompt).toContain("## Soul");
expect(callArgs.systemPrompt).toContain("I am a CEO who prioritizes high-impact work");
});
it("agent WITHOUT identity (no soul, instructions, memory) still exits with no_assignment", async () => {
// Agent with empty strings should also exit gracefully
const store = createStoreWithAgentForExec({
taskId: undefined,
soul: "",
instructionsText: "",
memory: "",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
// Should NOT create a session for agents without identity
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(result.resultJson).toEqual({ reason: "no_assignment" });
});
it("identity agent without task includes messaging tools when messageStore is available", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue([]),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
// Should have messaging tools when messageStore is available
expect(toolNames).toContain("fn_send_message");
expect(toolNames).toContain("fn_read_messages");
});
it("identity agent without task does NOT include messaging tools when messageStore is unavailable", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
// Should NOT have messaging tools when messageStore is not available
expect(toolNames).not.toContain("fn_send_message");
expect(toolNames).not.toContain("fn_read_messages");
});
it("identity agent without task fetches messages and includes them in prompt for timer trigger", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-notask-1",
fromId: "user-1",
content: "Please check the task board",
}),
createMessage({
id: "msg-notask-2",
fromId: "agent-5",
content: "Delegating FN-100 to you",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "timer",
triggerDetail: "scheduled",
});
expect(result.status).toBe("completed");
// Messages should be fetched for no-task runs too
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
// Messages should be marked as read after successful execution
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
// Verify execution prompt included the messages
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("Please check the task board");
expect(executionPrompt).toContain("Delegating FN-100 to you");
});
});
describe("blocked-task dedup", () => {
const buildContextHash = (blockedBy: string, taskDetail: Partial<TaskDetail>): string => {
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
const lastCommentId = taskDetail.comments?.at(-1)?.id;
const lastSteeringCommentId = taskDetail.steeringComments?.at(-1)?.id;
return Buffer.from(
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
)
.toString("base64")
.slice(0, 16);
};
it("skips duplicate blocked comments when blocked snapshot is unchanged", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
const taskDetail = {
id: "FN-BLOCKED",
title: "Blocked Task",
description: "Blocked task description",
prompt: "",
status: "queued",
blockedBy: "FN-DEP-1",
comments: [{ id: "comment-1", text: "Still blocked", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
steeringComments: [],
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail;
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
taskId: "FN-BLOCKED",
blockedBy: "FN-DEP-1",
recordedAt: "2026-01-01T00:00:00.000Z",
contextHash: buildContextHash("FN-DEP-1", taskDetail),
});
mockTaskStore = createMockTaskStore({
getTask: vi.fn().mockResolvedValue(taskDetail),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.resultJson).toEqual({ reason: "blocked_duplicate", taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" });
expect(mockTaskStore.addComment).not.toHaveBeenCalled();
expect(store.setLastBlockedState).not.toHaveBeenCalled();
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("re-logs blocked state when new comments change context hash", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
taskId: "FN-BLOCKED",
blockedBy: "FN-DEP-1",
recordedAt: "2026-01-01T00:00:00.000Z",
contextHash: "stale-context-hash",
});
const taskDetail = {
id: "FN-BLOCKED",
title: "Blocked Task",
description: "Blocked task description",
prompt: "",
status: "queued",
blockedBy: "FN-DEP-1",
comments: [{ id: "comment-2", text: "New context", author: "user", createdAt: "2026-01-02T00:00:00.000Z" }],
steeringComments: [],
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail;
mockTaskStore = createMockTaskStore({ getTask: vi.fn().mockResolvedValue(taskDetail) });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.resultJson).toEqual({ reason: "blocked", taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" });
expect(mockTaskStore.addComment).toHaveBeenCalledOnce();
expect(store.setLastBlockedState).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ taskId: "FN-BLOCKED", blockedBy: "FN-DEP-1" }),
);
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("treats changed blockedBy as a new blocked state", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
(store.getLastBlockedState as ReturnType<typeof vi.fn>).mockResolvedValue({
taskId: "FN-BLOCKED",
blockedBy: "FN-DEP-OLD",
recordedAt: "2026-01-01T00:00:00.000Z",
contextHash: "samehash",
});
const taskDetail = {
id: "FN-BLOCKED",
title: "Blocked Task",
description: "Blocked task description",
prompt: "",
status: "queued",
blockedBy: "FN-DEP-NEW",
comments: [],
steeringComments: [],
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail;
mockTaskStore = createMockTaskStore({ getTask: vi.fn().mockResolvedValue(taskDetail) });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockTaskStore.addComment).toHaveBeenCalledOnce();
expect(store.setLastBlockedState).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ blockedBy: "FN-DEP-NEW" }),
);
});
it("clears blocked state when task is no longer blocked", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-READY" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
getTask: vi.fn().mockResolvedValue({
id: "FN-READY",
title: "Ready Task",
description: "Ready to run",
prompt: "",
status: undefined,
blockedBy: undefined,
comments: [],
steeringComments: [],
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(store.clearLastBlockedState).toHaveBeenCalledWith("agent-001");
});
});
// ── Utility Lane Independence Regression ─────────────────────────────────────
// FN-1727: Heartbeat runs must execute on the control-plane (utility) lane
// and must NOT consume task-lane semaphore slots. This test proves that
// heartbeat execution completes successfully even when task execution
// slots are saturated (e.g., maxConcurrent: 0 or all slots occupied).
// The utility AI helper path must remain responsive under task-lane pressure.
describe("slot-saturation: heartbeat runs on utility lane independent of task-lane semaphore", () => {
it("executes heartbeat successfully while task-lane semaphore is saturated", async () => {
// Import AgentSemaphore directly to create a saturated slot fixture
const { AgentSemaphore } = await import("./concurrency.js");
// Create a semaphore with maxConcurrent=0 to simulate fully saturated state
// The defensive guard in AgentSemaphore.limit returns minimum 1, so we
// use a static limit of 0 and manually acquire to simulate saturation.
const taskLaneSemaphore = new AgentSemaphore(0);
// Acquire the single available slot to saturate task lanes
await taskLaneSemaphore.acquire();
// Verify the semaphore is saturated (no available slots)
expect(taskLaneSemaphore.availableCount).toBe(0);
expect(taskLaneSemaphore.activeCount).toBe(1);
// Create the heartbeat monitor (it does NOT receive the task-lane semaphore)
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
// Execute heartbeat while task lanes are saturated
// This MUST succeed because heartbeat runs on the utility lane
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
// CRITICAL ASSERTIONS:
// 1. Heartbeat completed successfully (proves it didn't wait for task-lane slot)
expect(result).toBeDefined();
expect(result.status).toBe("completed");
// 2. Agent session was created (proves execution proceeded)
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
// 3. Semaphore saturation is still held (proves heartbeat didn't consume task-lane slot)
expect(taskLaneSemaphore.activeCount).toBe(1);
// 4. Semaphore available count is still 0 (still saturated from task-lane perspective)
expect(taskLaneSemaphore.availableCount).toBe(0);
// Cleanup: release the task-lane slot
taskLaneSemaphore.release();
expect(taskLaneSemaphore.activeCount).toBe(0);
});
it("completes on_demand heartbeat while task-lane slots are fully occupied", async () => {
const { AgentSemaphore } = await import("./concurrency.js");
// Simulate multiple task-lane agents holding all slots
const taskLaneSemaphore = new AgentSemaphore(2);
// Saturate both slots with "task-lane agents"
await taskLaneSemaphore.acquire(); // Agent 1
await taskLaneSemaphore.acquire(); // Agent 2
expect(taskLaneSemaphore.availableCount).toBe(0);
// Now execute heartbeat - it should complete without waiting
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const startTime = Date.now();
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
const elapsed = Date.now() - startTime;
// Should complete quickly (not blocked by semaphore wait)
expect(elapsed).toBeLessThan(500);
// Heartbeat should succeed
expect(result.status).toBe("completed");
// Task-lane slots should remain occupied
expect(taskLaneSemaphore.activeCount).toBe(2);
// Cleanup
taskLaneSemaphore.release();
taskLaneSemaphore.release();
});
});
describe("executeHeartbeat - message processing", () => {
it("includes unread messages in prompt when woken by wake-on-message", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-1",
fromId: "agent-2",
fromType: "agent",
content: "Hello from agent-2",
createdAt: "2024-01-15T10:30:00.000Z",
}),
createMessage({
id: "msg-2",
fromId: "user-1",
content: "Hello from user",
createdAt: "2024-01-15T11:00:00.000Z",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
triggerDetail: "wake-on-message",
});
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalled();
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
// Verify execution prompt (passed to promptWithFallback) included the messages
// The execution prompt is passed to session.prompt by promptWithFallback mock
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
});
it("does not include message section when no unread messages", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue([]),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
triggerDetail: "wake-on-message",
});
expect(result.status).toBe("completed");
// Verify prompt did NOT include pending messages section
// Note: without wake-on-message trigger, no messages are fetched
// so the prompt won't have the Pending Messages section at all
});
it("marks messages as read after successful heartbeat execution", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-1",
fromId: "agent-2",
content: "Hello from agent-2",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
triggerDetail: "wake-on-message",
});
expect(result.status).toBe("completed");
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
});
it("does not mark messages as read on failed heartbeat execution", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Execution failed"));
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-1",
fromId: "agent-2",
content: "Hello from agent-2",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
triggerDetail: "wake-on-message",
});
expect(result.status).toBe("failed");
expect(messageStore.markAllAsRead).not.toHaveBeenCalled();
});
it("fetches messages for timer-triggered runs when messageStore is available", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-1",
fromId: "agent-2",
content: "Reminder about task FN-001",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
// Use a timer trigger (not wake-on-message)
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "timer",
triggerDetail: "scheduled",
});
expect(result.status).toBe("completed");
// Messages should be fetched even for timer triggers
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
// Messages should be marked as read after successful execution
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
// Verify execution prompt included the messages
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("Reminder about task FN-001");
});
it("fetches messages for assignment-triggered runs when messageStore is available", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-assign-1",
fromId: "user-1",
content: "Please work on this task",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
// Use an assignment trigger
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "assignment",
triggerDetail: "task-assigned",
});
expect(result.status).toBe("completed");
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
// Verify execution prompt included the messages
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("Please work on this task");
});
it("fetches messages for on-demand runs without wake-on-message trigger", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-od-1",
fromId: "agent-3",
content: "Status update: task FN-002 is complete",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
// Use on-demand trigger without wake-on-message
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
triggerDetail: "manual",
});
expect(result.status).toBe("completed");
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
// Verify execution prompt included the messages
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("Status update: task FN-002 is complete");
});
it("still fetches messages for wake-on-message triggers", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-wom-1",
fromId: "agent-2",
content: "Hello from agent-2",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
triggerDetail: "wake-on-message",
});
expect(result.status).toBe("completed");
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
// Verify execution prompt included the messages
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("Hello from agent-2");
});
describe("end-to-end message flow", () => {
it("proves wake-on-message can surface a user message and send a linked reply", async () => {
const messages: Map<string, Message[]> = new Map();
let messageCounter = 0;
const fakeMessageStore = {
setMessageToAgentHook: vi.fn(),
sendMessage: vi.fn((input: Omit<Message, "id" | "read" | "createdAt" | "updatedAt">) => {
const id = `msg-${++messageCounter}`;
const createdAt = new Date().toISOString();
const msg: Message = {
id,
...input,
read: false,
createdAt,
updatedAt: createdAt,
};
const key = `${input.toId}:${input.toType}`;
const inbox = messages.get(key) || [];
inbox.push(msg);
messages.set(key, inbox);
return msg;
}),
getInbox: vi.fn((participantId: string, participantType: string, opts?: { read?: boolean }) => {
const key = `${participantId}:${participantType}`;
const inbox = messages.get(key) || [];
if (opts?.read === false) return inbox.filter((message) => !message.read);
return inbox;
}),
getMailbox: vi.fn((participantId: string, participantType: string) => {
const key = `${participantId}:${participantType}`;
const inbox = messages.get(key) || [];
return { unreadCount: inbox.filter((message) => !message.read).length, messages: inbox };
}),
markAllAsRead: vi.fn((participantId: string, participantType: string) => {
const key = `${participantId}:${participantType}`;
const inbox = messages.get(key) || [];
inbox.forEach((message) => {
message.read = true;
});
messages.set(key, inbox);
}),
} as unknown as MessageStore;
const inboundFromUser = fakeMessageStore.sendMessage({
fromId: "dashboard",
fromType: "user",
toId: "agent-beta",
toType: "agent",
content: "Can you post a status update?",
type: "user-to-agent",
});
const store = createStoreWithAgentForExec({
id: "agent-beta",
name: "Agent Beta",
state: "active",
taskId: "FN-001",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({
store,
messageStore: fakeMessageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-beta",
source: "on_demand",
triggerDetail: "wake-on-message",
});
expect(result.status).toBe("completed");
const promptCalls = mockSession.prompt.mock.calls;
const executionPrompt = promptCalls[promptCalls.length - 1]?.[0] as string;
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain(`[id: ${inboundFromUser.id}]`);
expect(executionPrompt).toContain("dashboard");
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const sendMessageTool = callArgs.customTools?.find((tool: { name: string }) => tool.name === "fn_send_message");
expect(sendMessageTool).toBeDefined();
await sendMessageTool!.execute(
"tool-call",
{
to_id: "dashboard",
content: "Status: I am on it.",
type: "agent-to-user",
reply_to_message_id: inboundFromUser.id,
},
undefined,
undefined,
{} as any,
);
const dashboardInbox = fakeMessageStore.getInbox("dashboard", "user");
const linkedReply = dashboardInbox.find((message) => message.content === "Status: I am on it.");
expect(linkedReply?.metadata).toEqual({ replyTo: { messageId: inboundFromUser.id } });
await monitor.stop();
});
});
});
describe("executeHeartbeat - inbox selection", () => {
const makeInboxSelection = (taskId: string, priority: "in_progress" | "todo" | "blocked" = "todo") => {
const now = new Date().toISOString();
return {
task: {
id: taskId,
description: `Inbox task ${taskId}`,
column: priority === "in_progress" ? "in-progress" : "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: now,
updatedAt: now,
},
priority,
reason: `selected:${priority}`,
} as any;
};
it("when agent has no taskId, inbox selects a todo task and assigns it", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent,
getTask: vi.fn().mockResolvedValue({
id: "FN-INBOX",
title: "Inbox Task",
description: "Inbox-selected task",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001");
expect(store.assignTask).toHaveBeenCalledWith("agent-001", "FN-INBOX", expect.objectContaining({ agentId: "agent-001" }));
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-INBOX");
});
it("explicit taskId override takes precedence over inbox selection", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
taskId: "FN-EXPLICIT",
});
expect(selectNextTaskForAgent).not.toHaveBeenCalled();
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-EXPLICIT");
});
it("agent's existing taskId takes precedence over inbox selection", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-EXISTING" });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).not.toHaveBeenCalled();
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-EXISTING");
});
it("when inbox returns null, heartbeat completes with no_assignment", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(null);
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001");
expect(result.resultJson).toEqual({ reason: "no_assignment" });
});
it("records inbox selection metadata in resultJson", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent: vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo")),
getTask: vi.fn().mockResolvedValue({
id: "FN-INBOX",
title: "Inbox Task",
description: "Inbox-selected task",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result.resultJson).toEqual(expect.objectContaining({
reason: "inbox_selected",
priority: "todo",
taskId: "FN-INBOX",
}));
});
it("supports in-progress inbox selections before todo", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent: vi.fn().mockResolvedValue(makeInboxSelection("FN-RESUME", "in_progress")),
getTask: vi.fn().mockResolvedValue({
id: "FN-RESUME",
title: "Resume task",
description: "Resume in-progress work",
prompt: "",
steps: [],
column: "in-progress",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-RESUME");
expect(result.resultJson).toEqual(expect.objectContaining({
reason: "inbox_selected",
priority: "in_progress",
taskId: "FN-RESUME",
}));
});
it("gracefully skips inbox selection when checkoutTask throws", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-CHECKOUT", "todo"));
const checkoutTask = vi.fn().mockRejectedValue(new Error("Task is already checked out"));
mockTaskStore = createMockTaskStore({
selectNextTaskForAgent,
checkoutTask: checkoutTask as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001");
expect(checkoutTask).toHaveBeenCalledWith("FN-CHECKOUT", "agent-001", expect.objectContaining({ agentId: "agent-001" }));
expect(result.resultJson).toEqual({ reason: "no_assignment" });
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
});
describe("execution", () => {
it("no-task system prompt does not reference fn_task_log or task_document tools", () => {
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_log");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_document_write");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_document_read");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("task_document");
});
it("no-task system prompt references only available tools", () => {
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_task_create");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_list_agents");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_delegate_task");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_send_message");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_read_messages");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_memory_search");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_memory_get");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_memory_append");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_heartbeat_done");
});
it("task-scoped system prompt still references fn_task_log and fn_task_document tools", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_log");
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_document_write");
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_document tools");
});
it("both prompts include memory boundaries section", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("## Memory Boundaries");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("## Memory Boundaries");
});
it("both prompts instruct replies to include reply_to_message_id", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("reply_to_message_id");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("reply_to_message_id");
});
it("no-task system prompt processing messages section does not reference fn_task_log", () => {
const processingMessagesSection = HEARTBEAT_NO_TASK_SYSTEM_PROMPT.split("## Processing Messages")[1] ?? "";
expect(processingMessagesSection).not.toContain("fn_task_log");
});
it("creates session with enriched system prompt and expected tools", async () => {
const store = createStoreWithAgentForExec({
soul: "Act like a practical teammate who prioritizes clarity.",
memory: "Recent runs found flaky tests in integration suites.",
instructionsText: "Always log blockers with actionable next steps.",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.cwd).toBe("/tmp/test");
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain("## Soul");
expect(callArgs.systemPrompt).toContain("Act like a practical teammate who prioritizes clarity.");
expect(callArgs.systemPrompt).toContain("## Agent Memory");
expect(callArgs.systemPrompt).toContain("Recent runs found flaky tests in integration suites.");
expect(callArgs.systemPrompt).toContain("Always log blockers with actionable next steps.");
expect(callArgs.systemPrompt).toContain("## Project Memory");
expect(callArgs.systemPrompt).toContain("fn_memory_search");
expect(callArgs.systemPrompt).toContain("fn_task_log");
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("readonly");
// Tools: fn_task_create, fn_task_log, fn_task_document_write, fn_task_document_read, fn_list_agents, fn_delegate_task,
// fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(10);
expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
expect(callArgs.customTools![3]!.name).toBe("fn_task_document_read");
expect(callArgs.customTools![4]!.name).toBe("fn_list_agents");
expect(callArgs.customTools![5]!.name).toBe("fn_delegate_task");
expect(callArgs.customTools![6]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![7]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![8]!.name).toBe("fn_memory_append");
// fn_heartbeat_done is last (terminal tool)
expect(callArgs.customTools![9]!.name).toBe("fn_heartbeat_done");
});
it("includes memory instructions even when agent has no custom instructions", async () => {
const store = createStoreWithAgentForExec({
soul: undefined,
memory: undefined,
instructionsText: undefined,
instructionsPath: undefined,
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain("## Project Memory");
});
it("omits memory tools and instructions when project memory is disabled", async () => {
const store = createStoreWithAgentForExec();
const taskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ memoryEnabled: false }),
} as Partial<TaskStore>);
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
expect(callArgs.systemPrompt).not.toContain("## Project Memory");
expect(toolNames).not.toContain("fn_memory_search");
expect(toolNames).not.toContain("fn_memory_get");
expect(toolNames).not.toContain("fn_memory_append");
});
it("wires user-created agent memory into the fn_memory_search tool", async () => {
const store = createStoreWithAgentForExec({
name: "CEO",
memory: "Prioritize roadmap sequencing and delegate implementation follow-ups.",
});
const taskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ memoryBackendType: "file" }),
} as Partial<TaskStore>);
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
const memorySearch = callArgs.customTools!.find((tool: any) => tool.name === "fn_memory_search") as any;
expect(memorySearch).toBeDefined();
const result = await memorySearch.execute("call-1", {
query: "roadmap delegate",
limit: 5,
}, undefined, undefined, undefined);
expect(result.content[0].text).toContain(".fusion/agent-memory/agent-001/MEMORY.md");
expect(result.content[0].text).toContain("roadmap sequencing");
expect(result.details.results[0].backend).toBe("agent-memory");
});
it("includes document tools in heartbeat session", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
const toolNames = callArgs.customTools!.map((t: any) => t.name);
expect(toolNames).toContain("fn_task_document_write");
expect(toolNames).toContain("fn_task_document_read");
});
it("fn_heartbeat_done is the terminal tool (last in array)", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
const toolNames = callArgs.customTools!.map((t: any) => t.name);
// fn_heartbeat_done should be last for stable terminal signaling
expect(toolNames[toolNames.length - 1]).toBe("fn_heartbeat_done");
});
it("calls promptWithFallback with task context", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment", triggerDetail: "new task assigned" });
expect(mockSession.prompt).toHaveBeenCalledOnce();
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
expect(promptArg).toContain("agent-001");
expect(promptArg).toContain("Test Task");
expect(promptArg).toContain("assignment");
expect(promptArg).toContain("new task assigned");
expect(promptArg).toContain("PROMPT.md");
});
it("includes triggering comment context in execution prompt when comment IDs are provided", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
mockTaskStore.getTask = vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "# Prompt",
comments: [{ id: "c-1", author: "user", text: "Please cover edge cases", createdAt: "2026-01-01T00:00:00.000Z" }],
steeringComments: [{ id: "s-1", author: "agent", text: "Investigating blocker", createdAt: "2026-01-01T00:01:00.000Z" }],
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
triggeringCommentIds: ["c-1", "s-1"],
triggeringCommentType: "steering",
});
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
expect(promptArg).toContain("You were woken because of new comments on this task");
expect(promptArg).toContain("Please cover edge cases");
expect(promptArg).toContain("Investigating blocker");
});
it("keeps standard prompt when no triggering comments are provided", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
expect(promptArg).not.toContain("You were woken because of new comments on this task");
expect(promptArg).not.toContain("New comments since last run:");
});
it("completes run with status completed on successful execution", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
// Agent state should be set back to active
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
// Session should be disposed
expect(mockSession.dispose).toHaveBeenCalled();
});
it("uses explicit taskId override instead of agent.taskId", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-DEFAULT" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
// Override getTask to return a different task
mockTaskStore.getTask = vi.fn().mockResolvedValue({
id: "FN-OVERRIDE",
title: "Override Task",
description: "Override description",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({
agentId: "agent-001",
source: "on_demand",
taskId: "FN-OVERRIDE",
});
// Should have fetched the override task
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-OVERRIDE");
// fn_task_log tool should use the override task ID
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
const taskLogTool = callArgs.customTools![1]!;
expect(taskLogTool.name).toBe("fn_task_log");
});
it("passes model config from agent runtimeConfig to createFnAgent", async () => {
const store = createStoreWithAgentForExec({
runtimeConfig: { modelProvider: "openai", modelId: "gpt-4o" },
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.defaultProvider).toBe("openai");
expect(callArgs.defaultModelId).toBe("gpt-4o");
});
it("passes undefined model when runtimeConfig has no model", async () => {
const store = createStoreWithAgentForExec({ runtimeConfig: {} });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.defaultProvider).toBeUndefined();
expect(callArgs.defaultModelId).toBeUndefined();
});
it("persists contextSnapshot on run records", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "assignment",
triggerDetail: "task-assigned",
triggeringCommentIds: ["comment-1"],
triggeringCommentType: "task",
contextSnapshot: {
wakeReason: "assignment",
triggerDetail: "task-assigned",
taskId: "FN-001",
},
});
expect(result.contextSnapshot).toEqual({
wakeReason: "assignment",
triggerDetail: "task-assigned",
taskId: "FN-001",
triggeringCommentIds: ["comment-1"],
triggeringCommentType: "task",
});
});
it("records agent logs, context taskId, and stdoutExcerpt for successful runs", async () => {
const store = createStoreWithAgentForExec();
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
mockTaskStore = createMockTaskStore({ appendAgentLog });
const mockSession = createMockAgentSession();
let onText: ((delta: string) => void) | undefined;
let onToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
let onToolEnd: ((name: string, isError: boolean, result?: unknown) => void) | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
onText = opts.onText;
onToolStart = opts.onToolStart;
onToolEnd = opts.onToolEnd;
return { session: mockSession as any };
});
mockSession.prompt = vi.fn().mockImplementation(async () => {
onText?.("Heartbeat produced visible output");
onToolStart?.("read", { path: "README.md" });
onToolEnd?.("read", false, "done");
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "Heartbeat produced visible output", "text", undefined, "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", "README.md", "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", "done", "executor");
expect(result.contextSnapshot?.taskId).toBe("FN-001");
expect(result.stdoutExcerpt).toContain("Heartbeat produced visible output");
});
});
describe("fn_heartbeat_done tool", () => {
it("captures summary from fn_heartbeat_done in resultJson", async () => {
const store = createStoreWithAgentForExec();
let capturedDoneTool: any;
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
// fn_heartbeat_done is last in the customTools array (index 4)
capturedDoneTool = opts.customTools[opts.customTools.length - 1];
return { session: mockSession as any };
});
// Simulate: when prompt is called, invoke the fn_heartbeat_done tool
mockSession.prompt = vi.fn().mockImplementation(async (prompt: string) => {
// Simulate the agent calling fn_heartbeat_done
const result = await capturedDoneTool.execute("call-1", { summary: "Checked task, all good" });
expect(result.content[0].text).toContain("Heartbeat complete");
expect(result.content[0].text).toContain("Checked task, all good");
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const run = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(run.resultJson).toBeDefined();
expect((run.resultJson as any).summary).toBe("Checked task, all good");
});
it("works without summary in fn_heartbeat_done", async () => {
const store = createStoreWithAgentForExec();
let capturedDoneTool: any;
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedDoneTool = opts.customTools[opts.customTools.length - 1];
return { session: mockSession as any };
});
mockSession.prompt = vi.fn().mockImplementation(async () => {
await capturedDoneTool.execute("call-1", {});
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const run = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(run.resultJson).toBeDefined();
expect((run.resultJson as any).summary).toBeUndefined();
});
});
describe("fn_task_create tool", () => {
it("creates a task in the store when fn_task_create tool is called", async () => {
const store = createStoreWithAgentForExec();
let capturedCreateTool: any;
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedCreateTool = opts.customTools[0]; // fn_task_create
return { session: mockSession as any };
});
mockSession.prompt = vi.fn().mockImplementation(async () => {
await capturedCreateTool.execute("call-1", { description: "Follow-up task" });
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockTaskStore.createTask).toHaveBeenCalledWith({
description: "Follow-up task",
dependencies: undefined,
column: "triage",
});
});
});
describe("error handling", () => {
it("completes run as failed when createFnAgent throws", async () => {
const store = createStoreWithAgentForExec();
mockedCreateFnAgent.mockRejectedValue(new Error("Model unavailable"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("failed");
expect(result.stderrExcerpt).toContain("Model unavailable");
// Agent state should be set to error
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "error");
});
it("completes run as failed when promptWithFallback throws", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Prompt failed"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("failed");
expect(result.stderrExcerpt).toContain("Prompt failed");
// Session should still be disposed in finally block
expect(mockSession.dispose).toHaveBeenCalled();
// Agent should be untracked
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
});
it("flushes AgentLogger on execution failure", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
const flushSpy = vi.spyOn(AgentLogger.prototype, "flush").mockResolvedValue(undefined);
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Prompt failed"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(flushSpy).toHaveBeenCalled();
});
it("flushes AgentLogger when session creation fails", async () => {
const store = createStoreWithAgentForExec();
const flushSpy = vi.spyOn(AgentLogger.prototype, "flush").mockResolvedValue(undefined);
mockedCreateFnAgent.mockRejectedValue(new Error("Model unavailable"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(flushSpy).toHaveBeenCalled();
});
});
describe("concurrency", () => {
it("serializes concurrent executeHeartbeat calls for the same agent", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
let promptCallCount = 0;
// Make prompt take some time to ensure overlap
mockSession.prompt = vi.fn().mockImplementation(async () => {
promptCallCount++;
await new Promise((resolve) => setTimeout(resolve, 10));
});
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
// We need getRunDetail to return different runs for each call
let runCount = 0;
const concurrentSavedRuns: Map<string, AgentHeartbeatRun> = new Map();
(store.startHeartbeatRun as ReturnType<typeof vi.fn>).mockImplementation(async () => {
runCount++;
return {
id: `run-${runCount}`,
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
});
(store.saveRun as ReturnType<typeof vi.fn>).mockImplementation(async (run: AgentHeartbeatRun) => {
concurrentSavedRuns.set(run.id, run);
});
(store.getRunDetail as ReturnType<typeof vi.fn>).mockImplementation(async (_agentId: string, runId: string) => {
return concurrentSavedRuns.get(runId) ?? {
id: runId,
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "completed" as const,
};
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
// Fire two concurrent executions
const [result1, result2] = await Promise.all([
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }),
monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }),
]);
// Both should complete
expect(result1).toBeDefined();
expect(result2).toBeDefined();
// Both should have called prompt (serialized, not concurrent)
expect(promptCallCount).toBe(2);
});
});
describe("usage tracking", () => {
it("records estimated output tokens in usageJson", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
let onTextCallback: ((delta: string) => void) | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
onTextCallback = opts.onText;
return { session: mockSession as any };
});
// Simulate text output
mockSession.prompt = vi.fn().mockImplementation(async () => {
// Simulate 100 chars of output (roughly 25 tokens at 4 chars/token)
if (onTextCallback) {
onTextCallback("A".repeat(100));
}
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result.usageJson).toBeDefined();
expect(result.usageJson!.inputTokens).toBe(0);
expect(result.usageJson!.outputTokens).toBe(25); // 100/4 = 25
expect(result.usageJson!.cachedTokens).toBe(0);
});
it("accumulates usage on agent record", async () => {
const store = createStoreWithAgentForExec({
totalInputTokens: 100,
totalOutputTokens: 200,
});
const mockSession = createMockAgentSession();
let onTextCallback: ((delta: string) => void) | undefined;
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
onTextCallback = opts.onText;
return { session: mockSession as any };
});
mockSession.prompt = vi.fn().mockImplementation(async () => {
if (onTextCallback) {
onTextCallback("A".repeat(100));
}
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
// Should update cumulative tokens: 200 + 25 = 225
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", {
totalInputTokens: 100,
totalOutputTokens: 225,
});
});
});
describe("cleanup", () => {
it("disposes session and untracks agent even on error", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Crash"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
// Session disposed
expect(mockSession.dispose).toHaveBeenCalled();
// Agent untracked
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
});
it("disposes session and untracks agent on success", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockSession.dispose).toHaveBeenCalled();
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
});
});
describe("Budget Governance", () => {
it("skips heartbeat when agent is over budget (timer)", async () => {
const budgetStatus = createBudgetStatus({
currentUsage: 10000,
budgetLimit: 10000,
usagePercent: 100,
thresholdPercent: 80,
isOverBudget: true,
isOverThreshold: true,
});
const store = createStoreWithAgentForExec();
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(budgetStatus);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
expect(result.resultJson).toMatchObject({ reason: "budget_exhausted", budgetStatus });
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
});
it("skips heartbeat when agent is over budget (on_demand)", async () => {
const store = createStoreWithAgentForExec();
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result.resultJson).toMatchObject({ reason: "budget_exhausted" });
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("skips heartbeat when agent is over budget (assignment)", async () => {
const store = createStoreWithAgentForExec();
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
expect(result.resultJson).toMatchObject({ reason: "budget_exhausted" });
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("skips timer heartbeat when agent is over threshold but not over budget", async () => {
const budgetStatus = createBudgetStatus({
currentUsage: 850,
budgetLimit: 1000,
usagePercent: 85,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: true,
});
const store = createStoreWithAgentForExec();
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(budgetStatus);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.resultJson).toMatchObject({ reason: "budget_threshold_exceeded", budgetStatus });
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
});
it("allows on_demand heartbeat when agent is over threshold", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({ isOverThreshold: true, usagePercent: 85, budgetLimit: 1000, thresholdPercent: 80 })
);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
});
it("allows assignment heartbeat when agent is over threshold", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({ isOverThreshold: true, usagePercent: 85, budgetLimit: 1000, thresholdPercent: 80 })
);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
});
it("proceeds normally when agent is below threshold", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({ isOverBudget: false, isOverThreshold: false, usagePercent: 30, budgetLimit: 1000, thresholdPercent: 80 })
);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
});
it("proceeds normally when getBudgetStatus throws", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("budget unavailable"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
});
});
});
// ── Task Creation Tracking Tests ──────────────────────────────────────
describe("createHeartbeatTools", () => {
let mockTaskStore: TaskStore;
function createMockTaskStoreForTools(overrides: Partial<TaskStore> = {}): TaskStore {
return {
createTask: vi.fn().mockResolvedValue({
id: "FN-100",
description: "Follow-up task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
// Document-related methods for task_document tools
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocuments: vi.fn().mockResolvedValue([]),
...overrides,
} as unknown as TaskStore;
}
beforeEach(() => {
mockTaskStore = createMockTaskStoreForTools();
});
it("returns fn_task_create, fn_task_log, fn_task_document_write, and fn_task_document_read tools", () => {
const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
expect(tools).toHaveLength(6);
expect(tools[0]!.name).toBe("fn_task_create");
expect(tools[1]!.name).toBe("fn_task_log");
expect(tools[2]!.name).toBe("fn_task_document_write");
expect(tools[3]!.name).toBe("fn_task_document_read");
expect(tools[4]!.name).toBe("fn_list_agents");
expect(tools[5]!.name).toBe("fn_delegate_task");
});
it("fn_task_create tool creates a task in triage via TaskStore", async () => {
const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
const createTool = tools[0]!;
const result = await createTool.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any);
expect(mockTaskStore.createTask).toHaveBeenCalledWith({
description: "Follow-up task",
dependencies: undefined,
column: "triage",
});
const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : "";
expect(responseText).toContain("Created FN-100");
expect((result.details as any).taskId).toBe("FN-100");
expect(result.details).toEqual({ taskId: "FN-100" });
});
it("fn_task_create details includes taskId matching mock store return", async () => {
const store = createMockStore();
const matchingStore = createMockTaskStoreForTools({
createTask: vi.fn().mockResolvedValue({
id: "ZX-321",
description: "Follow-up task",
dependencies: [],
column: "triage",
}),
});
const monitor = new HeartbeatMonitor({ store, taskStore: matchingStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", matchingStore, "FN-001");
const result = await tools[0]!.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any);
expect((result.details as any).taskId).toBe("ZX-321");
});
it("fn_task_create tracking uses details.taskId for non-standard ID prefixes", async () => {
const store = createMockStore();
const prefixedTaskStore = createMockTaskStoreForTools({
createTask: vi.fn().mockResolvedValue({
id: "ABC-999",
description: "Follow-up task",
dependencies: [],
column: "triage",
}),
});
const monitor = new HeartbeatMonitor({ store, taskStore: prefixedTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", prefixedTaskStore, "FN-001");
await tools[0]!.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any);
expect(prefixedTaskStore.logEntry).toHaveBeenCalledWith(
"ABC-999",
"Created by agent agent-001 during heartbeat run",
undefined,
undefined,
);
});
it("fn_task_create tracking falls back to unknown when details has no taskId", async () => {
const store = createMockStore();
const createTaskCreateToolSpy = vi.spyOn(agentTools, "createTaskCreateTool").mockReturnValue({
name: "fn_task_create",
label: "Create Task",
description: "Create a task",
parameters: {} as any,
execute: vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Created PROJ-777: Follow-up task" }],
details: {},
}),
} as any);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
try {
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
await tools[0]!.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any);
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"unknown",
"Created by agent agent-001 during heartbeat run",
undefined,
undefined,
);
} finally {
createTaskCreateToolSpy.mockRestore();
}
});
it("fn_task_create tracking handles missing details gracefully", async () => {
const store = createMockStore();
const missingDetailsTaskStore = createMockTaskStoreForTools({
createTask: vi.fn().mockResolvedValue({
id: undefined,
description: "Follow-up task",
dependencies: [],
column: "triage",
}),
});
const monitor = new HeartbeatMonitor({ store, taskStore: missingDetailsTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", missingDetailsTaskStore, "FN-001");
const result = await tools[0]!.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any);
expect(result).toBeDefined();
expect(missingDetailsTaskStore.logEntry).toHaveBeenCalledWith(
"unknown",
"Created by agent agent-001 during heartbeat run",
undefined,
undefined,
);
});
it("logs agent link on created task", async () => {
const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
await tools[0]!.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any);
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"FN-100",
"Created by agent agent-001 during heartbeat run",
undefined,
undefined,
);
});
it("accumulates created tasks in runCreatedTasks", async () => {
const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
await tools[0]!.execute("call-1", { description: "First task" }, undefined as any, undefined as any, undefined as any);
await tools[0]!.execute("call-2", { description: "Second task" }, undefined as any, undefined as any, undefined as any);
// Internally tracked — verify via completeRun integration
// For now verify the tool was called twice
expect(mockTaskStore.createTask).toHaveBeenCalledTimes(2);
});
it("handles logEntry failure gracefully", async () => {
mockTaskStore.logEntry = vi.fn().mockRejectedValue(new Error("DB error"));
const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
// Should not throw even though logEntry fails
const result = await tools[0]!.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any);
expect(result).toBeDefined();
// Task was still created
expect(mockTaskStore.createTask).toHaveBeenCalled();
});
it("fn_task_document_write tool persists documents via TaskStore", async () => {
const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
const writeTool = tools.find((t) => t.name === "fn_task_document_write")!;
const result = await writeTool.execute("call-1", { key: "plan", content: "Implementation plan here" }, undefined as any, undefined as any, undefined as any);
expect(mockTaskStore.upsertTaskDocument).toHaveBeenCalledWith("FN-001", {
key: "plan",
content: "Implementation plan here",
author: "agent",
});
const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : "";
expect(responseText).toContain("Saved document");
expect(responseText).toContain("plan");
});
it("fn_task_document_read tool reads specific document by key", async () => {
const store = createMockStore();
mockTaskStore.getTaskDocument = vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "plan",
content: "Implementation plan content",
revision: 2,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
const readTool = tools.find((t) => t.name === "fn_task_document_read")!;
const result = await readTool.execute("call-1", { key: "plan" }, undefined as any, undefined as any, undefined as any);
expect(mockTaskStore.getTaskDocument).toHaveBeenCalledWith("FN-001", "plan");
const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : "";
expect(responseText).toContain("plan");
expect(responseText).toContain("Implementation plan content");
});
it("fn_task_document_read tool lists all documents when key is omitted", async () => {
const store = createMockStore();
mockTaskStore.getTaskDocuments = vi.fn().mockResolvedValue([
{ id: "doc-1", taskId: "FN-001", key: "plan", content: "", revision: 1, author: "agent", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ id: "doc-2", taskId: "FN-001", key: "notes", content: "", revision: 1, author: "agent", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
const readTool = tools.find((t) => t.name === "fn_task_document_read")!;
const result = await readTool.execute("call-1", { key: undefined }, undefined as any, undefined as any, undefined as any);
expect(mockTaskStore.getTaskDocuments).toHaveBeenCalledWith("FN-001");
const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : "";
expect(responseText).toContain("plan");
expect(responseText).toContain("notes");
});
});
describe("completeRun task tracking", () => {
it("includes tasksCreated in resultJson when tasks were created", async () => {
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
const store = createMockStore();
const mockTaskStore: TaskStore = {
createTask: vi.fn().mockResolvedValue({
id: "FN-200",
description: "Created task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
} as unknown as TaskStore;
// Set up store to return a run that we can verify
const initialRun: AgentHeartbeatRun = {
id: "run-track-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
};
savedRuns.set("run-track-001", { ...initialRun });
(store as any).startHeartbeatRun = vi.fn().mockResolvedValue(initialRun);
(store as any).saveRun = vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
});
(store as any).getRunDetail = vi.fn().mockImplementation(async (_agentId: string, runId: string) => {
return savedRuns.get(runId);
});
(store as any).endHeartbeatRun = vi.fn().mockResolvedValue(undefined);
(store as any).getAgent = vi.fn().mockResolvedValue({
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
taskId: "FN-001",
runtimeConfig: {},
} as Agent);
(store as any).updateAgent = vi.fn().mockResolvedValue(undefined);
(store as any).updateAgentState = vi.fn().mockResolvedValue(undefined);
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
// Use createHeartbeatTools to create a task
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
await tools[0]!.execute("call-1", { description: "Created task" }, undefined as any, undefined as any, undefined as any);
// Now complete the run
await monitor.completeRun("agent-001", "run-track-001", {
status: "completed",
resultJson: { summary: "test" },
});
// Check the saved run has tasksCreated
const savedRun = savedRuns.get("run-track-001");
expect(savedRun).toBeDefined();
expect(savedRun!.resultJson).toBeDefined();
expect((savedRun!.resultJson as any).tasksCreated).toEqual([
{ id: "FN-200", description: "Created task" },
]);
// Original resultJson fields should still be present
expect((savedRun!.resultJson as any).summary).toBe("test");
});
it("does not include tasksCreated in resultJson when no tasks were created", async () => {
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
const store = createMockStore();
(store as any).saveRun = vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
});
(store as any).getRunDetail = vi.fn().mockResolvedValue({
id: "run-empty-001",
agentId: "agent-002",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun);
(store as any).endHeartbeatRun = vi.fn().mockResolvedValue(undefined);
(store as any).updateAgentState = vi.fn().mockResolvedValue(undefined);
const monitor = new HeartbeatMonitor({ store });
await monitor.completeRun("agent-002", "run-empty-001", {
status: "completed",
resultJson: { summary: "nothing created" },
});
const savedRun = savedRuns.get("run-empty-001");
expect(savedRun).toBeDefined();
expect((savedRun!.resultJson as any).tasksCreated).toBeUndefined();
expect((savedRun!.resultJson as any).summary).toBe("nothing created");
});
});
describe("Budget Governance", () => {
function createCompleteRunBudgetStore(options: {
agent?: Partial<Agent>;
budgetStatus?: AgentBudgetStatus;
budgetStatusError?: Error;
} = {}): AgentStore {
const run: AgentHeartbeatRun = {
id: "run-budget-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
};
const agent: Agent = {
id: "agent-001",
name: "Budget Agent",
role: "executor",
state: "running",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
...options.agent,
} as Agent;
return {
getRunDetail: vi.fn().mockResolvedValue(run),
saveRun: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn().mockResolvedValue(agent),
updateAgent: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: options.budgetStatusError
? vi.fn().mockRejectedValue(options.budgetStatusError)
: vi.fn().mockResolvedValue(options.budgetStatus ?? createBudgetStatus()),
} as unknown as AgentStore;
}
it("pauses agent with budget-exhausted reason when run pushes usage over budget", async () => {
const store = createCompleteRunBudgetStore({
agent: { totalInputTokens: 950, totalOutputTokens: 0 },
budgetStatus: createBudgetStatus({
currentUsage: 1050,
budgetLimit: 1000,
usagePercent: 105,
thresholdPercent: 80,
isOverBudget: true,
isOverThreshold: true,
}),
});
const monitor = new HeartbeatMonitor({ store });
await monitor.completeRun("agent-001", "run-budget-001", {
status: "completed",
usageJson: { inputTokens: 0, outputTokens: 100, cachedTokens: 0 },
});
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
});
it("does not pause agent when below budget after run", async () => {
const store = createCompleteRunBudgetStore({
budgetStatus: createBudgetStatus({
currentUsage: 700,
budgetLimit: 1000,
usagePercent: 70,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: false,
}),
});
const monitor = new HeartbeatMonitor({ store });
await monitor.completeRun("agent-001", "run-budget-001", {
status: "completed",
usageJson: { inputTokens: 10, outputTokens: 50, cachedTokens: 0 },
});
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
});
it("does not pause agent when run fails (status=failed)", async () => {
const store = createCompleteRunBudgetStore({
budgetStatus: createBudgetStatus({ isOverBudget: true, isOverThreshold: true }),
});
const monitor = new HeartbeatMonitor({ store });
await monitor.completeRun("agent-001", "run-budget-001", {
status: "failed",
usageJson: { inputTokens: 10, outputTokens: 50, cachedTokens: 0 },
stderrExcerpt: "failure",
});
expect(store.getBudgetStatus).not.toHaveBeenCalled();
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "error");
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
});
it("does not pause agent when run is terminated", async () => {
const store = createCompleteRunBudgetStore({
budgetStatus: createBudgetStatus({ isOverBudget: true, isOverThreshold: true }),
});
const monitor = new HeartbeatMonitor({ store });
await monitor.completeRun("agent-001", "run-budget-001", {
status: "terminated",
usageJson: { inputTokens: 10, outputTokens: 50, cachedTokens: 0 },
});
expect(store.getBudgetStatus).not.toHaveBeenCalled();
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
});
it("does not pause agent when usageJson is undefined", async () => {
const store = createCompleteRunBudgetStore({
budgetStatus: createBudgetStatus({ isOverBudget: true, isOverThreshold: true }),
});
const monitor = new HeartbeatMonitor({ store });
await monitor.completeRun("agent-001", "run-budget-001", {
status: "completed",
});
expect(store.getBudgetStatus).not.toHaveBeenCalled();
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
});
});
describe("clearRunState", () => {
it("resets accumulated task state for an agent", async () => {
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
const store = createMockStore();
const mockTaskStore: TaskStore = {
createTask: vi.fn().mockResolvedValue({
id: "FN-300",
description: "Created task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({} as any),
} as unknown as TaskStore;
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
// Create a task via the tracking tools
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
await tools[0]!.execute("call-1", { description: "Task to track" }, undefined as any, undefined as any, undefined as any);
// Set up store to verify second completeRun
(store as any).saveRun = vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
});
(store as any).getRunDetail = vi.fn().mockResolvedValue({
id: "run-clear-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun);
(store as any).endHeartbeatRun = vi.fn().mockResolvedValue(undefined);
(store as any).updateAgentState = vi.fn().mockResolvedValue(undefined);
// First completeRun should have tasksCreated
await monitor.completeRun("agent-001", "run-clear-001", { status: "completed" });
let savedRun = savedRuns.get("run-clear-001");
expect((savedRun!.resultJson as any)?.tasksCreated).toEqual([
{ id: "FN-300", description: "Task to track" },
]);
// Reset mock for second run
savedRuns.clear();
(store as any).getRunDetail = vi.fn().mockResolvedValue({
id: "run-clear-002",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun);
// Second completeRun (after clearRunState) should NOT have tasksCreated
await monitor.completeRun("agent-001", "run-clear-002", { status: "completed" });
savedRun = savedRuns.get("run-clear-002");
expect((savedRun!.resultJson as any)?.tasksCreated).toBeUndefined();
});
});
});
// ─────────────────────────────────────────────────────────────────────────
// HeartbeatTriggerScheduler tests
// ─────────────────────────────────────────────────────────────────────────
describe("HeartbeatTriggerScheduler", () => {
let store: AgentStore;
let callback: ReturnType<typeof vi.fn>;
let scheduler: import("./agent-heartbeat.js").HeartbeatTriggerScheduler;
beforeEach(() => {
callback = vi.fn().mockResolvedValue(undefined);
store = {
getAgent: vi.fn().mockResolvedValue({
id: "agent-001",
name: "Agent 001",
role: "executor",
state: "active",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
on: vi.fn(),
off: vi.fn(),
} as unknown as AgentStore;
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
describe("constructor and lifecycle", () => {
it("starts and stops cleanly", () => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
expect(scheduler.isActive()).toBe(false);
scheduler.start();
expect(scheduler.isActive()).toBe(true);
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
it("start is idempotent", () => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
scheduler.start(); // second call should be no-op
expect(scheduler.isActive()).toBe(true);
});
it("stop is idempotent", () => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
scheduler.stop();
scheduler.stop(); // second call should be no-op
expect(scheduler.isActive()).toBe(false);
});
});
describe("registerAgent", () => {
beforeEach(() => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
});
it("registers an agent with timer", () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
it("registers regardless of the legacy enabled flag (state is the source of truth)", () => {
// runtimeConfig.enabled is no longer honored by the scheduler — pause
// and resume happen through agent.state, and the agent:updated listener
// drives register/unregister. Callers that still pass `enabled: false`
// should not silently lose the timer.
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000, enabled: false });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
it("applies default 3600-second interval when intervalMs is undefined", async () => {
vi.useFakeTimers();
scheduler.registerAgent("agent-001", {});
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Verify the default 3600-second interval fires
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3_600_000);
expect(callback).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("applies default 3600-second interval when intervalMs is 0", async () => {
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 0 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Verify the default 3600-second interval fires
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3_600_000);
expect(callback).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("applies default 3600-second interval when heartbeatIntervalMs is not set", async () => {
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { enabled: true });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Should fire at exactly 3600 seconds (default interval)
await vi.advanceTimersByTimeAsync(3_599_999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1); // Now at exactly 3600 seconds
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 3_600_000,
});
vi.useRealTimers();
});
it("uses explicit interval over default when both are provided", async () => {
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 15_000, enabled: true });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Should fire at 15 seconds (explicit), not 3600
await vi.advanceTimersByTimeAsync(14_999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1); // Now at exactly 15 seconds
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 15_000,
});
vi.useRealTimers();
});
it("applies heartbeatMultiplier to timer interval", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.5 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 60_000, enabled: true });
await Promise.resolve();
await Promise.resolve();
expect(taskStore.getSettings).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(29_999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({ intervalMs: 30_000 }));
vi.useRealTimers();
});
it("defaults multiplier to 1 when setting is missing", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({}),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 20_000, enabled: true });
await Promise.resolve();
await Promise.resolve();
expect(taskStore.getSettings).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(19_999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({ intervalMs: 20_000 }));
vi.useRealTimers();
});
it("clamps multiplied interval to 1000ms minimum", async () => {
scheduler.stop();
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.1 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
vi.useFakeTimers();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 2_000, enabled: true });
await Promise.resolve();
await Promise.resolve();
expect(taskStore.getSettings).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({ intervalMs: 1_000 }));
vi.useRealTimers();
});
it("clears previous timer when re-registering", () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 20000 });
expect(scheduler.getRegisteredAgents()).toHaveLength(1);
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
});
});
describe("unregisterAgent", () => {
beforeEach(() => {
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
});
it("removes a registered agent", () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
scheduler.unregisterAgent("agent-001");
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
});
it("is no-op for unregistered agent", () => {
scheduler.unregisterAgent("agent-999");
expect(scheduler.getRegisteredAgents()).toHaveLength(0);
});
});
describe("timer triggers", () => {
beforeEach(() => {
vi.useFakeTimers();
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
});
it("fires callback at the configured interval", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Advance by one interval and let async callbacks settle
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("clamps configured interval to a minimum of 1000ms", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10 });
await vi.advanceTimersByTimeAsync(999);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 1000,
});
});
it("fires multiple times for multiple intervals", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(15000);
expect(callback).toHaveBeenCalledTimes(3);
});
it("does not fire after stop", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
scheduler.stop();
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("does not fire after unregister", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
scheduler.unregisterAgent("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("skips tick when agent has active run", async () => {
(store.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-active",
status: "active",
});
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).not.toHaveBeenCalled();
});
it("respects maxConcurrentRuns from config", async () => {
// Agent with active run should be skipped
(store.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-active",
status: "active",
});
scheduler.registerAgent("agent-001", {
heartbeatIntervalMs: 5000,
maxConcurrentRuns: 1,
});
await vi.advanceTimersByTimeAsync(5000);
expect(callback).not.toHaveBeenCalled();
});
it("dispatches timer callback even when agent is over budget (budget enforcement in executeHeartbeat)", async () => {
// Budget checks have been moved from the scheduler to executeHeartbeat().
// The scheduler dispatches the callback so that executeHeartbeat() can create
// explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
);
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
// Callback IS called so executeHeartbeat() can create a run record
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("dispatches timer callback even when agent is over threshold (budget enforcement in executeHeartbeat)", async () => {
// Budget checks have been moved from the scheduler to executeHeartbeat().
// The scheduler dispatches the callback so that executeHeartbeat() can create
// explicit run records with budget_exhausted/budget_threshold_exceeded reasons.
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({
budgetLimit: 1000,
usagePercent: 85,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: true,
})
);
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
// Callback IS called so executeHeartbeat() can create a run record
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("fires timer tick normally when below threshold", async () => {
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
createBudgetStatus({
budgetLimit: 1000,
usagePercent: 30,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: false,
})
);
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("fires timer tick when getBudgetStatus throws", async () => {
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("budget unavailable"));
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
});
describe("stop clears all timers", () => {
it("clears all registered timers on stop", () => {
vi.useFakeTimers();
scheduler = new HeartbeatTriggerScheduler(store, callback);
scheduler.start();
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
scheduler.registerAgent("agent-002", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toHaveLength(2);
scheduler.stop();
expect(scheduler.getRegisteredAgents()).toHaveLength(0);
vi.advanceTimersByTime(20000);
expect(callback).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
// ── FN-2289 Regression: Idle-state timer persistence ─────────────────────────────────────
// These tests verify the fix for the defect where agent timers were unintentionally cleared
// when agents transitioned to "idle" state. The isTickableState() function must include "idle"
// as a valid state so that timers remain armed for agents between tasks.
describe("FN-2289: idle-state timer persistence", () => {
let eventStore: EventEmitter & {
getAgent: ReturnType<typeof vi.fn>;
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;
getBudgetStatus: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.useFakeTimers();
eventStore = Object.assign(new EventEmitter(), {
getAgent: vi.fn().mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
})),
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
});
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback);
scheduler.start();
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
it("timer remains armed when agent transitions to idle state (regression test for FN-2289)", async () => {
// Register agent with active state
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Simulate agent transitioning to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const, // Agent is now idle
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be registered
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Timer should fire for idle agent
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 5000,
});
});
it("timer fires correctly for idle agent at scheduled interval", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update agent to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be armed
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time and verify multiple fires
await vi.advanceTimersByTimeAsync(30000);
expect(callback).toHaveBeenCalledTimes(3);
});
it("timer fires for agent transitioning from idle back to active", async () => {
// Start with idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Timer should be armed even for idle agent
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time - timer should fire
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("idle agent receives timer trigger with correct context", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 15000 });
// Update to idle state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
await vi.advanceTimersByTimeAsync(15000);
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({
wakeReason: "timer",
triggerDetail: "scheduled",
intervalMs: 15000,
}));
});
it("timer is still armed after multiple idle state transitions", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
// First transition to idle
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "idle" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Second transition (still idle, but emit update)
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
// Third transition back to active
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "active", metadata: {} } as import("@fusion/core").Agent);
// Timer should still be registered through all transitions
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Timer should fire
await vi.advanceTimersByTimeAsync(10000);
expect(callback).toHaveBeenCalledOnce();
});
it("timer fires for running agent (pre-existing behavior)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
// Update to running state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "running" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "running", metadata: {} } as import("@fusion/core").Agent);
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("timer is unregistered when agent becomes terminated (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to terminated state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "terminated" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "terminated", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for terminated agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("timer is unregistered when agent becomes error state (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to error state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "error" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "error", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for error agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
it("timer is unregistered when agent becomes paused state (should clear timer)", async () => {
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Update to paused state
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "paused" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for paused agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
await vi.advanceTimersByTimeAsync(10000);
expect(callback).not.toHaveBeenCalled();
});
});
// ── FN-2289 Regression: Multiplier stability across re-registration ─────────────────────
describe("FN-2289: multiplier stability across re-registration", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
scheduler?.stop();
vi.useRealTimers();
});
it("multiplier-adjusted interval remains stable when re-registering", async () => {
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.5 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
// First registration with multiplier 0.5 -> effective interval 5000ms
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await vi.advanceTimersByTimeAsync(100); // Allow pending async operations to complete
// Re-register (simulating settings change or config update)
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await vi.advanceTimersByTimeAsync(100);
// Timer should still fire at the multiplied interval (5000ms)
// The timer was set up immediately, so we need to ensure we advance past 5000ms total
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
it("async multiplier registration does not stale-overwrite newer registration", async () => {
const taskStore = {
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 2.0 }),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
scheduler.start();
// Register with multiplier 2.0 -> effective interval 20000ms
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
await Promise.resolve();
// Immediately re-register before async multiplier completes
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
// Timer should still be registered
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
// Advance time past the original interval (10s) but before multiplied (20s)
// If stale-overwrite happens, callback would be called at 10s instead of 20s
await vi.advanceTimersByTimeAsync(15000);
expect(callback).not.toHaveBeenCalled();
// Timer should fire at 20s (correct multiplied interval)
await vi.advanceTimersByTimeAsync(5000);
expect(callback).toHaveBeenCalledOnce();
});
});
describe("assignment watching", () => {
let eventStore: EventEmitter & {
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;
getBudgetStatus: ReturnType<typeof vi.fn>;
getRecentRuns: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.useRealTimers(); // Ensure real timers for these tests
eventStore = Object.assign(new EventEmitter(), {
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
getBudgetStatus: vi.fn().mockRejectedValue(new Error("budget status unavailable")),
getRecentRuns: vi.fn().mockResolvedValue([]),
});
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback);
scheduler.start();
}, 30000);
afterEach(() => {
scheduler?.stop();
});
it("triggers callback on agent:assigned event", async () => {
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {}, taskId: "FN-001" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-001");
// Allow asynchronous assignment listeners to run in heavily loaded test environments.
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledOnce();
}, { timeout: 1000 });
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", {
taskId: "FN-001",
wakeReason: "assignment",
triggerDetail: "task-assigned",
});
});
it("does NOT trigger when stopped", async () => {
scheduler.stop();
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-002");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
it("skips trigger when agent heartbeat is disabled", async () => {
const agent: import("@fusion/core").Agent = {
id: "agent-test",
name: "executor-FN-1661",
role: "executor",
state: "active",
taskId: "FN-1661",
metadata: {},
runtimeConfig: { enabled: false },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
eventStore.emit("agent:assigned", agent, "FN-1661");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
expect(eventStore.getActiveHeartbeatRun).not.toHaveBeenCalled();
});
it("skips trigger when agent has active run", async () => {
(eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-active",
status: "active",
});
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-003");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
it("blocks assignment trigger when agent is over budget", async () => {
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(
createBudgetStatus({
agentId: "agent-test",
isOverBudget: true,
isOverThreshold: true,
usagePercent: 100,
budgetLimit: 1000,
thresholdPercent: 80,
})
);
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-003");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
it("allows assignment trigger when agent is over threshold", async () => {
const budgetStatus = createBudgetStatus({
agentId: "agent-test",
budgetLimit: 1000,
usagePercent: 85,
thresholdPercent: 80,
isOverBudget: false,
isOverThreshold: true,
});
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(budgetStatus);
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-003");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", {
taskId: "FN-003",
wakeReason: "assignment",
triggerDetail: "task-assigned",
budgetStatus,
});
});
it("passes budgetStatus in WakeContext for assignment triggers", async () => {
const budgetStatus = createBudgetStatus({
agentId: "agent-test",
budgetLimit: 1000,
usagePercent: 45,
thresholdPercent: 80,
});
(eventStore as any).getBudgetStatus = vi.fn().mockResolvedValue(budgetStatus);
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-005");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).toHaveBeenCalledWith(
"agent-test",
"assignment",
expect.objectContaining({
taskId: "FN-005",
budgetStatus,
}),
);
});
it("includes new steering comment IDs for assignment wakes when taskStore is available", async () => {
scheduler.stop();
(eventStore as any).getRecentRuns = vi.fn().mockResolvedValue([
{ startedAt: "2026-01-01T00:00:00.000Z" },
]);
const assignmentTaskStore = {
getTask: vi.fn().mockResolvedValue({
id: "FN-006",
steeringComments: [
{ id: "steer-old", text: "older", author: "user", createdAt: "2025-12-31T23:00:00.000Z" },
{ id: "steer-new", text: "new guidance", author: "user", createdAt: "2026-01-01T01:00:00.000Z" },
],
}),
} as unknown as TaskStore;
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback, assignmentTaskStore);
scheduler.start();
const agent = { id: "agent-test", name: "Test", state: "active", metadata: {} } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-006");
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledOnce();
}, { timeout: 1000 });
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", expect.objectContaining({
taskId: "FN-006",
triggeringCommentIds: ["steer-new"],
triggeringCommentType: "steering",
}));
});
it("cleans up listener on unwatch", async () => {
scheduler.unwatchAssignments();
const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent;
eventStore.emit("agent:assigned", agent, "FN-004");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(callback).not.toHaveBeenCalled();
});
});
describe("Run context propagation", () => {
it("createHeartbeatTools passes runContext to taskStore.logEntry", async () => {
// Create a minimal mock TaskStore
const mockTaskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-NEW", description: "New task" }),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "Test task",
column: "todo",
log: [],
}),
} as unknown as import("@fusion/core").TaskStore;
const monitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const runContext = { runId: "run-123", agentId: "agent-456", source: "timer" };
// Create tools with run context
const tools = monitor.createHeartbeatTools("agent-456", mockTaskStore, "FN-001", runContext);
// Find the fn_task_log tool and execute it
const taskLogTool = tools.find(t => t.name === "fn_task_log");
expect(taskLogTool).toBeDefined();
const result = await taskLogTool!.execute("call-1", { message: "Test log entry", outcome: undefined }, undefined as any, undefined as any, undefined as any);
// Verify logEntry was called with runContext
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"FN-001",
"Test log entry",
undefined,
runContext,
);
});
it("createHeartbeatTools tracks task creations with runContext", async () => {
// Create a minimal mock TaskStore
const mockTaskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-200", description: "New task created", dependencies: [] }),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "Test task",
column: "todo",
log: [],
}),
} as unknown as import("@fusion/core").TaskStore;
const monitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const runContext = { runId: "run-789", agentId: "agent-abc", source: "on_demand" };
// Create tools with run context
const tools = monitor.createHeartbeatTools("agent-abc", mockTaskStore, "FN-001", runContext);
// Find the fn_task_create tool and execute it
const taskCreateTool = tools.find(t => t.name === "fn_task_create");
expect(taskCreateTool).toBeDefined();
const result = await taskCreateTool!.execute("call-1", { description: "New task created" }, undefined as any, undefined as any, undefined as any);
// Verify logEntry was called with runContext for the created task
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"FN-200",
"Created by agent agent-abc during heartbeat run",
undefined,
runContext,
);
});
it("createHeartbeatTools works without runContext (backward compat)", async () => {
// Create a minimal mock TaskStore
const mockTaskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-NEW", description: "New task" }),
logEntry: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
description: "Test task",
column: "todo",
log: [],
}),
} as unknown as import("@fusion/core").TaskStore;
const monitor = new HeartbeatMonitor({
store,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
// Create tools without run context
const tools = monitor.createHeartbeatTools("agent-456", mockTaskStore, "FN-001");
// Find the fn_task_log tool and execute it
const taskLogTool = tools.find(t => t.name === "fn_task_log");
expect(taskLogTool).toBeDefined();
const result = await taskLogTool!.execute("call-1", { message: "Test log entry", outcome: undefined }, undefined as any, undefined as any, undefined as any);
// Verify logEntry was called without runContext
expect(mockTaskStore.logEntry).toHaveBeenCalledWith(
"FN-001",
"Test log entry",
undefined,
undefined,
);
});
});
});
describe("executeHeartbeat — skill selection resolver contract (FN-1510/FN-1511)", () => {
// We need to test the skill selection contract without affecting other tests.
// Since buildSessionSkillContextSync is called via dynamic import inside executeHeartbeat,
// we need to test the integration at a higher level - verifying that createFnAgent
// receives the skillSelection option when agent has skills.
// Helper: create a mock session returned by createFnAgent
function createMockAgentSession() {
return {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
model: { provider: "mock", id: "mock-model" },
};
}
let mockTaskStore: TaskStore;
// Helper: create a basic mock task store
function createMockTaskStore(): TaskStore {
return {
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "# Test PROMPT.md\nSome content",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "Created task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
addComment: vi.fn().mockResolvedValue({}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocuments: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
}
// Helper: create a mock store that returns a specific agent
function createStoreWithAgentForExec(agentData: Partial<Agent> = {}): AgentStore {
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: { skills: ["test-skill"] },
...agentData,
} as Agent;
// Track saved runs so getRunDetail returns the most recent state
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
return {
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn().mockResolvedValue(mockAgent),
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
mockAgent.taskId = taskId;
return mockAgent;
}),
startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun),
saveRun: vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
}),
getRunDetail: vi.fn().mockImplementation(async (_agentId: string, runId: string) => {
return savedRuns.get(runId) ?? {
id: runId,
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "completed" as const,
};
}),
getRatingSummary: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),
getLastBlockedState: vi.fn().mockResolvedValue(null),
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentStore;
}
beforeEach(() => {
mockTaskStore = createMockTaskStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// These tests verify the skill selection contract at the createFnAgent level.
// Since we can't easily mock dynamic imports, we verify that when an agent has
// skills in metadata, the createFnAgent is called and the result includes skill info.
it("createFnAgent is called with agent session for heartbeat with skills", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: { skills: ["heartbeat-skill"] },
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockedCreateFnAgent).toHaveBeenCalled();
expect(result.status).toBe("completed");
});
it("createFnAgent is called with correct cwd for skill resolution", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: { skills: ["custom-skill"] },
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/project/root" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(mockedCreateFnAgent).toHaveBeenCalled();
const firstCall = mockedCreateFnAgent.mock.calls[0];
const opts = firstCall[0];
expect(opts.cwd).toBe("/project/root");
});
it("heartbeat completes successfully when agent has no skills", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
// Agent with empty metadata (no skills)
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: {},
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalled();
});
});
describe("executeHeartbeat — skill selection non-fatal (FN-1510/FN-1511)", () => {
// Helper: create a mock session returned by createFnAgent
function createMockAgentSession() {
return {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
model: { provider: "mock", id: "mock-model" },
};
}
let mockTaskStore: TaskStore;
// Helper: create a basic mock task store
function createMockTaskStore(): TaskStore {
return {
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test Task",
description: "Test task description",
prompt: "# Test PROMPT.md\nSome content",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "Created task",
dependencies: [],
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue({}),
addComment: vi.fn().mockResolvedValue({}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
upsertTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocument: vi.fn().mockResolvedValue({
id: "doc-1",
taskId: "FN-001",
key: "test-plan",
content: "Test document content",
revision: 1,
author: "agent",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
getTaskDocuments: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
}
// Helper: create a mock store that returns a specific agent
function createStoreWithAgentForExec(agentData: Partial<Agent> = {}): AgentStore {
const mockAgent: Agent = {
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
...agentData,
} as Agent;
// Track saved runs so getRunDetail returns the most recent state
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
return {
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
getAgent: vi.fn().mockResolvedValue(mockAgent),
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
mockAgent.taskId = taskId;
return mockAgent;
}),
startHeartbeatRun: vi.fn().mockResolvedValue({
id: "run-001",
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: null,
status: "active",
} as AgentHeartbeatRun),
saveRun: vi.fn().mockImplementation(async (run: AgentHeartbeatRun) => {
savedRuns.set(run.id, run);
}),
getRunDetail: vi.fn().mockImplementation(async (_agentId: string, runId: string) => {
return savedRuns.get(runId) ?? {
id: runId,
agentId: "agent-001",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
status: "completed" as const,
};
}),
getRatingSummary: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),
getLastBlockedState: vi.fn().mockResolvedValue(null),
setLastBlockedState: vi.fn().mockResolvedValue(undefined),
clearLastBlockedState: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentStore;
}
beforeEach(() => {
mockTaskStore = createMockTaskStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// These tests verify that skill selection is non-fatal - heartbeat completes
// regardless of skill selection outcome
it("heartbeat completes when agent has empty metadata", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: {},
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
});
it("heartbeat completes when agent has various skill configurations", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: createMockAgentSession(),
} as any);
// Test with various skill metadata configurations
const skillConfigs = [
{ skills: ["single-skill"] },
{ skills: ["a", "b", "c"] },
{ skills: [] },
{ skills: ["skill-with-dashes", "another_skill"] },
];
for (const skills of skillConfigs) {
vi.clearAllMocks();
const store = createStoreWithAgentForExec({
taskId: "FN-001",
metadata: skills,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalled();
}
});
});