feat(FN-3308): fix CSS token consistency in settings and task detail modals
Fixes CSS token consistency issues in TaskDetailModal and SettingsModal, and updates the corresponding mobile overflow test to match the refactored styles. Fusion-Task-Id: FN-3308
This commit is contained in:
File diff suppressed because it is too large
Load Diff
2638
packages/engine/src/__tests__/heartbeat-executor.test.ts
Normal file
2638
packages/engine/src/__tests__/heartbeat-executor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
HeartbeatMonitor,
|
||||
HeartbeatTriggerScheduler,
|
||||
isBlockedStateDuplicate,
|
||||
type AgentSession,
|
||||
type HeartbeatExecutionOptions,
|
||||
HEARTBEAT_SYSTEM_PROMPT,
|
||||
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
HEARTBEAT_NO_TASK_PROCEDURE,
|
||||
} 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";
|
||||
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
|
||||
vi.mock("../logger.js", async () => {
|
||||
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
|
||||
return {
|
||||
createLogger: vi.fn(() => createMockLogger()),
|
||||
heartbeatLog: createMockLogger(),
|
||||
formatError: formatMockError,
|
||||
};
|
||||
});
|
||||
import { heartbeatLog } from "../logger.js";
|
||||
|
||||
let store: AgentStore;
|
||||
let monitor: HeartbeatMonitor;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
monitor = new HeartbeatMonitor({ store });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
monitor.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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", expect.any(String));
|
||||
|
||||
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", expect.any(String));
|
||||
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
783
packages/engine/src/__tests__/heartbeat-monitor.test.ts
Normal file
783
packages/engine/src/__tests__/heartbeat-monitor.test.ts
Normal file
@@ -0,0 +1,783 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
HeartbeatMonitor,
|
||||
HeartbeatTriggerScheduler,
|
||||
isBlockedStateDuplicate,
|
||||
type AgentSession,
|
||||
type HeartbeatExecutionOptions,
|
||||
HEARTBEAT_SYSTEM_PROMPT,
|
||||
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
HEARTBEAT_NO_TASK_PROCEDURE,
|
||||
} 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";
|
||||
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
|
||||
vi.mock("../logger.js", async () => {
|
||||
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
|
||||
return {
|
||||
createLogger: vi.fn(() => createMockLogger()),
|
||||
heartbeatLog: createMockLogger(),
|
||||
formatError: formatMockError,
|
||||
};
|
||||
});
|
||||
import { heartbeatLog } from "../logger.js";
|
||||
|
||||
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", expect.any(String));
|
||||
|
||||
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", expect.any(String));
|
||||
|
||||
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.any(String));
|
||||
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.any(String));
|
||||
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(3);
|
||||
expect(warnMessages.some((message) => message.includes("Terminating unresponsive agent agent-001"))).toBe(true);
|
||||
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.any(String));
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
1191
packages/engine/src/__tests__/heartbeat-scheduler.test.ts
Normal file
1191
packages/engine/src/__tests__/heartbeat-scheduler.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
640
packages/engine/src/__tests__/heartbeat-session-prompt.test.ts
Normal file
640
packages/engine/src/__tests__/heartbeat-session-prompt.test.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
HeartbeatMonitor,
|
||||
HeartbeatTriggerScheduler,
|
||||
isBlockedStateDuplicate,
|
||||
type AgentSession,
|
||||
type HeartbeatExecutionOptions,
|
||||
HEARTBEAT_SYSTEM_PROMPT,
|
||||
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
HEARTBEAT_NO_TASK_PROCEDURE,
|
||||
} 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";
|
||||
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
|
||||
vi.mock("../logger.js", async () => {
|
||||
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
|
||||
return {
|
||||
createLogger: vi.fn(() => createMockLogger()),
|
||||
heartbeatLog: createMockLogger(),
|
||||
formatError: formatMockError,
|
||||
};
|
||||
});
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
promptWithFallback: vi.fn(async (session: any, prompt: string) => {
|
||||
await session.prompt(prompt);
|
||||
}),
|
||||
}));
|
||||
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",
|
||||
source: {
|
||||
sourceType: "agent_heartbeat",
|
||||
sourceAgentId: "agent-001",
|
||||
sourceRunId: undefined,
|
||||
},
|
||||
}, expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
|
||||
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
668
packages/engine/src/__tests__/heartbeat-skills.test.ts
Normal file
668
packages/engine/src/__tests__/heartbeat-skills.test.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
HeartbeatMonitor,
|
||||
HeartbeatTriggerScheduler,
|
||||
isBlockedStateDuplicate,
|
||||
type AgentSession,
|
||||
type HeartbeatExecutionOptions,
|
||||
HEARTBEAT_SYSTEM_PROMPT,
|
||||
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
HEARTBEAT_NO_TASK_PROCEDURE,
|
||||
} 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";
|
||||
import { createMockStore, createMockSession, createMockMessageStore, createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
|
||||
vi.mock("../logger.js", async () => {
|
||||
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
|
||||
return {
|
||||
createLogger: vi.fn(() => createMockLogger()),
|
||||
heartbeatLog: createMockLogger(),
|
||||
formatError: formatMockError,
|
||||
};
|
||||
});
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
promptWithFallback: vi.fn(async (session: any, prompt: string) => {
|
||||
await session.prompt(prompt);
|
||||
}),
|
||||
}));
|
||||
import { createFnAgent } from "../pi.js";
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
|
||||
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),
|
||||
appendRunLog: 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),
|
||||
appendRunLog: 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// New observability tests (FN-3xxx sweep)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HeartbeatMonitor observability — prompt persistence + run-scoped logs", () => {
|
||||
// These tests use the same mock infrastructure as the main executeHeartbeat suite.
|
||||
let mockTaskStore: TaskStore;
|
||||
let mockAgent: Agent;
|
||||
|
||||
function createMockAgentSession() {
|
||||
return {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
model: { provider: "mock", id: "mock-model" },
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTaskStore(overrides: Partial<TaskStore> = {}): 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({}),
|
||||
getTaskDocument: vi.fn().mockResolvedValue(null),
|
||||
getTaskDocuments: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createStoreWithAgent(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;
|
||||
|
||||
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-obs-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),
|
||||
appendRunLog: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockTaskStore = createMockTaskStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("no-task heartbeat run persists systemPrompt and executionPrompt on the run record", async () => {
|
||||
// Identity agent (has soul) so a no-task run is triggered
|
||||
const store = createStoreWithAgent({ taskId: undefined, soul: "I am the ambient coordinator." });
|
||||
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" });
|
||||
|
||||
// saveRun should have been called with both prompt fields populated
|
||||
const saveRunCalls = (store.saveRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
// Find the call that includes systemPrompt (the prompt-persistence saveRun)
|
||||
const promptRunCall = saveRunCalls.find(
|
||||
(args: unknown[]) => typeof (args[0] as AgentHeartbeatRun).systemPrompt === "string" && ((args[0] as AgentHeartbeatRun).systemPrompt?.length ?? 0) > 0
|
||||
);
|
||||
expect(promptRunCall).toBeDefined();
|
||||
const savedRun = promptRunCall![0] as AgentHeartbeatRun;
|
||||
expect(savedRun.systemPrompt).toBeDefined();
|
||||
expect(typeof savedRun.systemPrompt).toBe("string");
|
||||
expect(savedRun.executionPrompt).toBeDefined();
|
||||
expect(typeof savedRun.executionPrompt).toBe("string");
|
||||
// heartbeatProcedureSource should be "default" (no custom procedure file)
|
||||
expect(savedRun.heartbeatProcedureSource).toBe("default");
|
||||
|
||||
// The execution prompt should contain the procedure text before the no-task action menu
|
||||
expect(savedRun.executionPrompt).toContain("Identity Snapshot");
|
||||
expect(savedRun.executionPrompt).toContain("Heartbeat Procedure");
|
||||
// The wake delta header should appear before the action menu items
|
||||
const procedureIdx = savedRun.executionPrompt!.indexOf("Heartbeat Procedure");
|
||||
const actionMenuIdx = savedRun.executionPrompt!.indexOf("No assigned task");
|
||||
expect(procedureIdx).toBeLessThan(actionMenuIdx);
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("no-task heartbeat run-scoped logs receive at least one entry after a simulated tick", async () => {
|
||||
const store = createStoreWithAgent({ taskId: undefined, soul: "I observe the project." });
|
||||
const mockSession = createMockAgentSession();
|
||||
let capturedOnText: ((delta: string) => void) | undefined;
|
||||
|
||||
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
||||
capturedOnText = opts.onText;
|
||||
return { session: mockSession as any };
|
||||
});
|
||||
|
||||
// Simulate the session emitting a text delta during prompt
|
||||
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
||||
capturedOnText?.("I am reviewing the project state.");
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
// appendRunLog should have been called on the AgentStore at least once
|
||||
const appendRunLogCalls = (store.appendRunLog as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(appendRunLogCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify the entry shape: agentId, runId, entry
|
||||
const [callAgentId, callRunId, callEntry] = appendRunLogCalls[0] as [string, string, unknown];
|
||||
expect(callAgentId).toBe("agent-001");
|
||||
expect(callRunId).toBe("run-obs-001");
|
||||
expect(callEntry).toMatchObject({ type: expect.stringMatching(/^(text|thinking|tool|tool_result|tool_error)$/) });
|
||||
});
|
||||
|
||||
it("task-scoped heartbeat persists systemPrompt and executionPrompt with procedure before task content", async () => {
|
||||
const store = createStoreWithAgent({ taskId: "FN-001" });
|
||||
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" });
|
||||
|
||||
const saveRunCalls = (store.saveRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const promptRunCall = saveRunCalls.find(
|
||||
(args: unknown[]) => typeof (args[0] as AgentHeartbeatRun).systemPrompt === "string" && ((args[0] as AgentHeartbeatRun).systemPrompt?.length ?? 0) > 0
|
||||
);
|
||||
expect(promptRunCall).toBeDefined();
|
||||
const savedRun = promptRunCall![0] as AgentHeartbeatRun;
|
||||
|
||||
// The execution prompt should have procedure before task description
|
||||
const procedureIdx = savedRun.executionPrompt!.indexOf("Heartbeat Procedure");
|
||||
const taskDescIdx = savedRun.executionPrompt!.indexOf("Task description:");
|
||||
expect(procedureIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(taskDescIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(procedureIdx).toBeLessThan(taskDescIdx);
|
||||
|
||||
// Identity Snapshot should appear in the execution prompt
|
||||
expect(savedRun.executionPrompt).toContain("## Identity Snapshot");
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("does not register a fn_identity tool (removed in favor of inline snapshot)", async () => {
|
||||
const store = createStoreWithAgent({ soul: "I am a senior executor.", memory: "Always log blockers." });
|
||||
let capturedTools: any[] | undefined;
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
||||
capturedTools = opts.customTools;
|
||||
return { session: mockSession as any };
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
expect(capturedTools).toBeDefined();
|
||||
expect(capturedTools!.find((t) => t.name === "fn_identity")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("inlines the Identity Snapshot block into the execution prompt for runtime-agnostic delivery", async () => {
|
||||
const store = createStoreWithAgent({
|
||||
taskId: undefined,
|
||||
soul: "I keep momentum across stalled tasks.",
|
||||
memory: "Always log blockers with concrete next steps.",
|
||||
});
|
||||
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" });
|
||||
|
||||
const saveRunCalls = (store.saveRun as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const promptRunCall = saveRunCalls.find(
|
||||
(args: unknown[]) => typeof (args[0] as AgentHeartbeatRun).executionPrompt === "string"
|
||||
&& ((args[0] as AgentHeartbeatRun).executionPrompt?.length ?? 0) > 0
|
||||
);
|
||||
expect(promptRunCall).toBeDefined();
|
||||
const savedRun = promptRunCall![0] as AgentHeartbeatRun;
|
||||
const exec = savedRun.executionPrompt!;
|
||||
|
||||
// Snapshot header + identity fields appear in the execution prompt body itself,
|
||||
// so non-pi runtimes (openclaw/hermes/paperclip) that may not propagate
|
||||
// customTools still see the agent's identity every tick. Snapshot carries
|
||||
// presence flags + content hashes only — full content lives in the system
|
||||
// prompt's Custom Instructions section.
|
||||
expect(exec).toContain("## Identity Snapshot");
|
||||
expect(exec).toContain("- agentId: agent-001");
|
||||
expect(exec).toMatch(/- soul: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
|
||||
expect(exec).toMatch(/- memory: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
|
||||
// Snapshot must NOT contain full preview content (that lives in the system prompt)
|
||||
expect(exec).not.toContain("I keep momentum across stalled tasks.");
|
||||
|
||||
// Snapshot must precede the Wake Delta and the Heartbeat Procedure
|
||||
const snapIdx = exec.indexOf("## Identity Snapshot");
|
||||
const wakeIdx = exec.indexOf("## Wake Delta");
|
||||
const procIdx = exec.indexOf("Heartbeat Procedure");
|
||||
expect(snapIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(snapIdx).toBeLessThan(wakeIdx);
|
||||
expect(wakeIdx).toBeLessThan(procIdx);
|
||||
});
|
||||
});
|
||||
|
||||
75
packages/engine/src/__tests__/heartbeat-test-helpers.ts
Normal file
75
packages/engine/src/__tests__/heartbeat-test-helpers.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { vi } from "vitest";
|
||||
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
|
||||
import type { AgentSession } from "../agent-heartbeat.js";
|
||||
|
||||
export function createMockStore(overrides: Partial<AgentStore> = {}): AgentStore {
|
||||
return {
|
||||
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgentState: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as AgentStore;
|
||||
}
|
||||
|
||||
export function createMockSession(): AgentSession {
|
||||
return {
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockMessageStore(onSetHook?: (hook: (message: Message) => void) => void): MessageStore {
|
||||
return {
|
||||
setMessageToAgentHook: vi.fn((hook: (message: Message) => void) => {
|
||||
onSetHook?.(hook);
|
||||
}),
|
||||
} as unknown as MessageStore;
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockLogger() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMockError(err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
const message = err.message || err.name || "Error";
|
||||
const stack = err.stack;
|
||||
return { message, stack, detail: stack ?? message };
|
||||
}
|
||||
const message = typeof err === "string" ? err : String(err);
|
||||
return { message, detail: message };
|
||||
}
|
||||
Reference in New Issue
Block a user