- Add bounded auto-restart handling in in-process runtime for durable agents after transient failures - Extend self-healing recovery logic and transient error detection to classify and recover retryable runtime interruptions - Add regression coverage across heartbeat executor, self-healing, and transient detector test suites - Document durable agent transient recovery behavior in docs/agents.md Fusion-Task-Id: FN-4005
3133 lines
134 KiB
TypeScript
3133 lines
134 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import {
|
|
HeartbeatMonitor,
|
|
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 type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message } from "@fusion/core";
|
|
import { 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", () => {
|
|
let mockTaskStore: TaskStore;
|
|
let mockAgent: Agent;
|
|
|
|
// Helper: create a mock session returned by createFnAgent
|
|
function createMockAgentSession() {
|
|
return {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
model: { provider: "mock", id: "mock-model" },
|
|
};
|
|
}
|
|
|
|
type MockTaskStoreOverrides = Partial<TaskStore> & {
|
|
checkoutTask?: (taskId: string, agentId: string) => Promise<unknown>;
|
|
};
|
|
|
|
// Helper: create a basic mock task store
|
|
function createMockTaskStore(overrides: MockTaskStoreOverrides = {}): TaskStore {
|
|
return {
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test Task",
|
|
description: "Test task description",
|
|
prompt: "# Test PROMPT.md\nSome content",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail),
|
|
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
createTask: vi.fn().mockResolvedValue({
|
|
id: "FN-002",
|
|
description: "Created task",
|
|
dependencies: [],
|
|
column: "triage",
|
|
}),
|
|
logEntry: vi.fn().mockResolvedValue({}),
|
|
addComment: vi.fn().mockResolvedValue({}),
|
|
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
|
// Document-related methods for task_document tools
|
|
upsertTaskDocument: vi.fn().mockResolvedValue({
|
|
id: "doc-1",
|
|
taskId: "FN-001",
|
|
key: "test-plan",
|
|
content: "Test document content",
|
|
revision: 1,
|
|
author: "agent",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
getTaskDocument: vi.fn().mockResolvedValue({
|
|
id: "doc-1",
|
|
taskId: "FN-001",
|
|
key: "test-plan",
|
|
content: "Test document content",
|
|
revision: 1,
|
|
author: "agent",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
getTaskDocuments: vi.fn().mockResolvedValue([]),
|
|
...overrides,
|
|
} as unknown as TaskStore;
|
|
}
|
|
|
|
// Helper: create a mock store that returns a specific agent
|
|
function createStoreWithAgentForExec(agentData: Partial<Agent> = {}): AgentStore {
|
|
mockAgent = {
|
|
id: "agent-001",
|
|
name: "Test Agent",
|
|
role: "executor",
|
|
state: "active",
|
|
taskId: "FN-001",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
metadata: {},
|
|
...agentData,
|
|
} as Agent;
|
|
|
|
// Track saved runs so getRunDetail returns the most recent state
|
|
const savedRuns: Map<string, AgentHeartbeatRun> = new Map();
|
|
|
|
return {
|
|
recordHeartbeat: vi.fn().mockResolvedValue(undefined),
|
|
updateAgentState: vi.fn().mockResolvedValue(undefined),
|
|
updateAgent: vi.fn().mockResolvedValue(undefined),
|
|
getAgent: vi.fn().mockResolvedValue(mockAgent),
|
|
assignTask: vi.fn().mockImplementation(async (_agentId: string, taskId: string | undefined) => {
|
|
mockAgent.taskId = taskId;
|
|
return mockAgent;
|
|
}),
|
|
claimTaskForAgent: vi.fn().mockImplementation(async (_agentId: string, _taskId: string) => ({
|
|
ok: false,
|
|
reason: "task_not_found",
|
|
})),
|
|
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),
|
|
getAgentsByReportsTo: vi.fn().mockResolvedValue([]),
|
|
} as unknown as AgentStore;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockTaskStore = createMockTaskStore();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("reports health check", () => {
|
|
it("buildReportsHealthSection returns null when agent has no reports", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await (monitor as any).buildReportsHealthSection("agent-001", store);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("buildReportsHealthSection returns formatted table for healthy reports", async () => {
|
|
const now = new Date().toISOString();
|
|
const store = createStoreWithAgentForExec();
|
|
vi.mocked(store.getAgentsByReportsTo).mockResolvedValue([
|
|
{ id: "agent-002", name: "agent-2", state: "active", taskId: "FN-100", lastHeartbeatAt: now, updatedAt: now } as Agent,
|
|
{ id: "agent-003", name: "agent-3", state: "running", taskId: "FN-101", lastHeartbeatAt: now, updatedAt: now } as Agent,
|
|
]);
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const section = await (monitor as any).buildReportsHealthSection("agent-001", store);
|
|
expect(section).toContain("## Reports Health Check");
|
|
expect(section).toContain("agent-2");
|
|
expect(section).toContain("agent-3");
|
|
expect(section).toContain("| Name | State | Task | Last Heartbeat | Health |");
|
|
expect(section).toContain("healthy");
|
|
});
|
|
|
|
it("buildReportsHealthSection classifies stuck agents", async () => {
|
|
const now = Date.now();
|
|
const store = createStoreWithAgentForExec();
|
|
vi.mocked(store.getAgentsByReportsTo).mockResolvedValue([
|
|
{ id: "agent-002", name: "agent-2", state: "error", taskId: "FN-100", lastHeartbeatAt: new Date(now - 1000).toISOString(), updatedAt: new Date(now - 1000).toISOString(), lastError: "boom" } as Agent,
|
|
]);
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp", heartbeatTimeoutMs: 60_000 });
|
|
|
|
const section = await (monitor as any).buildReportsHealthSection("agent-001", store);
|
|
expect(section).toContain("**stuck**");
|
|
expect(section).toContain("Actions for Unresponsive Reports");
|
|
});
|
|
|
|
it("buildReportsHealthSection classifies stale agents", async () => {
|
|
const now = Date.now();
|
|
const store = createStoreWithAgentForExec();
|
|
vi.mocked(store.getAgentsByReportsTo).mockResolvedValue([
|
|
{ id: "agent-003", name: "agent-3", state: "active", taskId: "FN-101", lastHeartbeatAt: new Date(now - 3 * 60 * 60 * 1000).toISOString(), updatedAt: new Date(now - 3 * 60 * 60 * 1000).toISOString() } as Agent,
|
|
]);
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp", heartbeatTimeoutMs: 60_000 });
|
|
|
|
const section = await (monitor as any).buildReportsHealthSection("agent-001", store);
|
|
expect(section).toContain("**stale**");
|
|
});
|
|
|
|
it("executeHeartbeat includes reports health section when agent has reports", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
|
const now = new Date().toISOString();
|
|
vi.mocked(store.getAgentsByReportsTo).mockResolvedValue([
|
|
{ id: "agent-010", name: "reporter", state: "running", taskId: "FN-200", lastHeartbeatAt: now, updatedAt: now } as Agent,
|
|
]);
|
|
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 executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
|
|
expect(executionPrompt).toContain("## Reports Health Check");
|
|
expect(executionPrompt).toContain("reporter");
|
|
});
|
|
|
|
it("executeHeartbeat omits reports health section when agent has no reports", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
|
vi.mocked(store.getAgentsByReportsTo).mockResolvedValue([]);
|
|
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 executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
|
|
expect(executionPrompt).not.toContain("## Reports Health Check");
|
|
});
|
|
});
|
|
|
|
it("passes action gate and permanent gating context for permanent heartbeat agents", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
const args = mockedCreateFnAgent.mock.calls[0]?.[0] as {
|
|
actionGateContext?: { agentId: string; isEphemeral: boolean };
|
|
permanentAgentGating?: { permissionPolicy?: { presetId: string } };
|
|
};
|
|
expect(args.actionGateContext?.agentId).toBe("agent-001");
|
|
expect(args.actionGateContext?.isEphemeral).toBe(false);
|
|
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
|
|
});
|
|
|
|
it("pauseForApproval pauses task and agent when taskId exists", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
|
const pauseTask = vi.fn().mockResolvedValue(undefined);
|
|
const logEntry = vi.fn().mockResolvedValue(undefined);
|
|
mockTaskStore = createMockTaskStore({ pauseTask, logEntry });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, "FN-001", "run-1");
|
|
await ctx.pauseForApproval({
|
|
approvalRequestId: "apr-1",
|
|
decision: {
|
|
disposition: "require-approval",
|
|
category: "command_execution",
|
|
toolName: "bash",
|
|
operation: "git commit",
|
|
summary: "bash: git commit",
|
|
resourceType: "git",
|
|
approvalDedupeKey: "dedupe-1",
|
|
metadata: {},
|
|
},
|
|
});
|
|
|
|
expect(pauseTask).toHaveBeenCalledWith("FN-001", true, undefined, { pausedByAgentId: "agent-001" });
|
|
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
|
|
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
|
|
});
|
|
|
|
it("pauseForApproval still pauses agent when taskId is undefined", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
const pauseTask = vi.fn().mockResolvedValue(undefined);
|
|
mockTaskStore = createMockTaskStore({ pauseTask });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, undefined, "run-1");
|
|
await ctx.pauseForApproval({
|
|
approvalRequestId: "apr-1",
|
|
decision: {
|
|
disposition: "require-approval",
|
|
category: "command_execution",
|
|
toolName: "bash",
|
|
operation: "git commit",
|
|
summary: "bash: git commit",
|
|
resourceType: "git",
|
|
approvalDedupeKey: "dedupe-1",
|
|
metadata: {},
|
|
},
|
|
});
|
|
|
|
expect(pauseTask).not.toHaveBeenCalled();
|
|
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
|
|
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
|
|
});
|
|
|
|
it("omits permanent-agent gating context for ephemeral heartbeat agents", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: "FN-001",
|
|
metadata: { agentKind: "task-worker" },
|
|
name: "executor-ephemeral",
|
|
reportsTo: undefined,
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
const args = mockedCreateFnAgent.mock.calls[0]?.[0] as {
|
|
permanentAgentGating?: unknown;
|
|
actionGateContext?: unknown;
|
|
};
|
|
expect(args.permanentAgentGating).toBeUndefined();
|
|
expect(args.actionGateContext).toBeUndefined();
|
|
});
|
|
|
|
describe("dependency validation", () => {
|
|
it("throws when taskStore is not configured", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const monitor = new HeartbeatMonitor({ store, rootDir: "/tmp" });
|
|
|
|
await expect(
|
|
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" })
|
|
).rejects.toThrow("HeartbeatMonitor not configured for execution (missing taskStore/rootDir)");
|
|
});
|
|
|
|
it("throws when rootDir is not configured", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore });
|
|
|
|
await expect(
|
|
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" })
|
|
).rejects.toThrow("HeartbeatMonitor not configured for execution (missing taskStore/rootDir)");
|
|
});
|
|
});
|
|
|
|
describe("graceful exit", () => {
|
|
it("completes with no_assignment when agent has no taskId and no explicit taskId", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toEqual({ reason: "no_assignment" });
|
|
// Should NOT have created an agent session
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("completes with invalid_state when agent state is paused", async () => {
|
|
const store = createStoreWithAgentForExec({ state: "paused" });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toEqual({ reason: "invalid_state", state: "paused" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
|
});
|
|
|
|
it("completes with invalid_state when agent state is error", async () => {
|
|
const store = createStoreWithAgentForExec({ state: "error" });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toEqual({ reason: "invalid_state", state: "error" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
|
});
|
|
|
|
it("keeps terminated as a run status while pausing the agent", async () => {
|
|
const store = createStoreWithAgentForExec({ state: "running" });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const run = await monitor.startRun("agent-001", { source: "on_demand" });
|
|
|
|
await monitor.completeRun("agent-001", run.id, {
|
|
status: "terminated",
|
|
stderrExcerpt: "Run stopped by user",
|
|
});
|
|
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "running");
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
|
|
expect(store.endHeartbeatRun).toHaveBeenCalledWith(run.id, "terminated");
|
|
});
|
|
|
|
it("clears stale lastError after a subsequent successful heartbeat run", async () => {
|
|
const store = createStoreWithAgentForExec({ state: "running" });
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const failedRun = await monitor.startRun("agent-001", { source: "on_demand" });
|
|
await monitor.completeRun("agent-001", failedRun.id, {
|
|
status: "failed",
|
|
stderrExcerpt: "Prompt failed",
|
|
});
|
|
|
|
const successfulRun = await monitor.startRun("agent-001", { source: "on_demand" });
|
|
await monitor.completeRun("agent-001", successfulRun.id, {
|
|
status: "completed",
|
|
});
|
|
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "error");
|
|
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", { lastError: "Prompt failed" });
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
|
|
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", { lastError: undefined });
|
|
});
|
|
|
|
it("completes as failed when agent not found in store", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
(store.getAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("failed");
|
|
expect(result.stderrExcerpt).toContain("not found");
|
|
});
|
|
|
|
it("completes with task_not_found when task does not exist", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-MISSING" });
|
|
mockTaskStore.getTask = vi.fn().mockRejectedValue(new Error("Task FN-MISSING not found"));
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toEqual({ reason: "task_not_found", taskId: "FN-MISSING" });
|
|
});
|
|
|
|
it("clears archived task assignments and falls back to a no-task heartbeat for identity agents", async () => {
|
|
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
|
mockTaskStore = createMockTaskStore({
|
|
appendAgentLog,
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-ARCHIVED",
|
|
title: "Archived Task",
|
|
description: "Archived task description",
|
|
prompt: "# Archived\n\nTask is archived",
|
|
steps: [],
|
|
column: "archived",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail),
|
|
});
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: "FN-ARCHIVED",
|
|
soul: "Monitor the project and handle ambient work.",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect((store.assignTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
|
"agent-001",
|
|
undefined,
|
|
expect.objectContaining({ agentId: "agent-001", source: "on_demand" }),
|
|
);
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const toolNames = mockedCreateFnAgent.mock.calls[0]![0]!.customTools!.map((tool: any) => tool.name);
|
|
expect(toolNames).not.toContain("fn_task_log");
|
|
expect(toolNames).not.toContain("fn_task_document_write");
|
|
expect(toolNames).not.toContain("fn_task_document_read");
|
|
expect(appendAgentLog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exits gracefully for explicit terminal task overrides that are not the agent's current assignment", async () => {
|
|
mockTaskStore = createMockTaskStore({
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-DONE",
|
|
title: "Done Task",
|
|
description: "Done task description",
|
|
prompt: "# Done\n\nTask is done",
|
|
steps: [],
|
|
column: "done",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail),
|
|
});
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: "FN-LIVE",
|
|
soul: "Stay helpful.",
|
|
});
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
taskId: "FN-DONE",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toEqual({ reason: "terminal_task", taskId: "FN-DONE", column: "done" });
|
|
expect(store.assignTask).not.toHaveBeenCalledWith("agent-001", undefined, expect.anything());
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── Identity Agents Without Tasks ─────────────────────────────────────────────
|
|
// FN-2051: Agents with identity (soul, instructions, memory) should run heartbeat
|
|
// sessions even without a task assignment, enabling them to do ambient work like
|
|
// messaging, memory management, task creation, and delegation.
|
|
describe("identity agents without tasks", () => {
|
|
it("agent WITH soul but no task creates session and completes successfully", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator agent who monitors project health" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
// Should create a session
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
// Reason should indicate identity run
|
|
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
|
});
|
|
|
|
it("auto-claim disabled skips candidate claiming during no-task runs", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: undefined,
|
|
soul: "I am a coordinator",
|
|
runtimeConfig: { autoClaimRelevantTasks: false },
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
mockTaskStore = createMockTaskStore({
|
|
listTasks: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "FN-CANDIDATE",
|
|
description: "executor workflow cleanup",
|
|
title: "Executor cleanup",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail,
|
|
]),
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect((store.claimTaskForAgent as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
|
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
|
|
expect(executionPrompt).toContain("auto-claim relevant tasks: disabled");
|
|
});
|
|
|
|
it("auto-claim enabled attempts to claim relevant no-task candidates", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "executor reliability owner" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
mockTaskStore = createMockTaskStore({
|
|
listTasks: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "FN-CANDIDATE",
|
|
description: "executor reliability follow-up",
|
|
title: "Executor reliability",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail,
|
|
]),
|
|
getTask: vi.fn().mockImplementation(async (id: string) => ({
|
|
id,
|
|
title: "Executor reliability",
|
|
description: "executor reliability follow-up",
|
|
prompt: "# PROMPT",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail)),
|
|
});
|
|
|
|
(store.claimTaskForAgent as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok: true,
|
|
task: { id: "FN-CANDIDATE" },
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(store.claimTaskForAgent).toHaveBeenCalledWith(
|
|
"agent-001",
|
|
"FN-CANDIDATE",
|
|
expect.objectContaining({ agentId: "agent-001", source: "timer" }),
|
|
);
|
|
const toolNames = mockedCreateFnAgent.mock.calls[0]![0]!.customTools!.map((tool: any) => tool.name);
|
|
expect(toolNames).toContain("fn_task_log");
|
|
});
|
|
|
|
it("auto-claim skips implementation candidates for non-executor agents", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: undefined,
|
|
role: "reviewer",
|
|
soul: "review workflows",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
mockTaskStore = createMockTaskStore({
|
|
listTasks: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "FN-CANDIDATE",
|
|
description: "executor reliability follow-up",
|
|
title: "Executor reliability",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail,
|
|
]),
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(store.claimTaskForAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("agent WITH instructionsText but no task creates session and completes successfully", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
|
});
|
|
|
|
it("agent WITH memory but no task creates session and completes successfully", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, memory: "Last week we shipped the new API. Watch for integration issues." });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
|
});
|
|
|
|
it("ephemeral agent with soul but no task still bails with no_assignment", async () => {
|
|
// Ephemeral agents (agentKind: "task-worker") should NOT run no-task sessions
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: undefined,
|
|
soul: "I am a task worker",
|
|
metadata: { agentKind: "task-worker" },
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
// Ephemeral agents should NOT create a session
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
// Should still exit with no_assignment (not no_assignment_identity_run)
|
|
expect(result.resultJson).toEqual({ reason: "no_assignment" });
|
|
});
|
|
|
|
it("identity agent without task receives correct tools (fn_task_create, fn_list_agents, fn_delegate_task, fn_heartbeat_done)", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
expect(callArgs.tools).toBe("coding");
|
|
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
|
|
|
|
// Should have fn_task_create, fn_list_agents, fn_delegate_task
|
|
expect(toolNames).toContain("fn_task_create");
|
|
expect(toolNames).toContain("fn_list_agents");
|
|
expect(toolNames).toContain("fn_delegate_task");
|
|
// Should have fn_heartbeat_done
|
|
expect(toolNames).toContain("fn_heartbeat_done");
|
|
// Should have memory tools
|
|
expect(toolNames).toContain("fn_memory_search");
|
|
expect(toolNames).toContain("fn_memory_append");
|
|
|
|
// Should NOT have fn_task_log, fn_task_document_write, fn_task_document_read (they require taskId)
|
|
expect(toolNames).not.toContain("fn_task_log");
|
|
expect(toolNames).not.toContain("fn_task_document_write");
|
|
expect(toolNames).not.toContain("fn_task_document_read");
|
|
});
|
|
|
|
it("no-task run receives HEARTBEAT_NO_TASK_SYSTEM_PROMPT as system prompt", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
const systemPrompt = callArgs.systemPrompt;
|
|
|
|
expect(systemPrompt).toContain(HEARTBEAT_NO_TASK_SYSTEM_PROMPT);
|
|
expect(systemPrompt).not.toContain("fn_task_log");
|
|
expect(systemPrompt).not.toContain("fn_task_document_write");
|
|
expect(systemPrompt).not.toContain("fn_task_document_read");
|
|
expect(systemPrompt).toContain("fn_task_create");
|
|
expect(systemPrompt).toContain("fn_list_agents");
|
|
expect(systemPrompt).toContain("fn_delegate_task");
|
|
expect(systemPrompt).toContain("fn_read_messages");
|
|
expect(systemPrompt).toContain("fn_send_message");
|
|
expect(systemPrompt).toContain("fn_memory_search");
|
|
expect(systemPrompt).toContain("fn_memory_append");
|
|
expect(systemPrompt).toContain('scope="agent"');
|
|
expect(systemPrompt).toContain('scope="project"');
|
|
expect(systemPrompt).toContain("fn_heartbeat_done");
|
|
});
|
|
|
|
it("identity agent without task receives no-task execution prompt mentioning 'no assigned task'", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
const systemPrompt = callArgs.systemPrompt;
|
|
expect(systemPrompt).toContain(HEARTBEAT_NO_TASK_SYSTEM_PROMPT);
|
|
expect(systemPrompt).not.toContain("fn_task_log");
|
|
expect(systemPrompt).not.toContain("fn_task_document_write");
|
|
expect(systemPrompt).not.toContain("fn_task_document_read");
|
|
expect(systemPrompt).not.toContain("Task Documents:");
|
|
expect(systemPrompt).toContain("fn_task_create");
|
|
expect(systemPrompt).toContain("fn_heartbeat_done");
|
|
expect(systemPrompt).toContain("fn_memory_append");
|
|
|
|
// The execution prompt is passed to session.prompt by promptWithFallback mock
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1]![0]!;
|
|
|
|
// Should mention no assigned task
|
|
expect(executionPrompt).toContain("No assigned task");
|
|
// Should describe ambient work capabilities
|
|
expect(executionPrompt).toContain("ambient work");
|
|
expect(executionPrompt).toContain("fn_task_create");
|
|
expect(executionPrompt).toContain("fn_list_agents");
|
|
expect(executionPrompt).toContain("fn_delegate_task");
|
|
// Should NOT include task-specific content
|
|
expect(executionPrompt).not.toContain("Assigned task:");
|
|
expect(executionPrompt).not.toContain("Task description:");
|
|
// Should include Wake Delta + no-task heartbeat procedure (tool-aligned per-tick anchoring)
|
|
expect(executionPrompt).toContain("## Wake Delta");
|
|
expect(executionPrompt).toContain("wake reason:");
|
|
expect(executionPrompt).toContain("autonomous heartbeat run");
|
|
expect(executionPrompt).toContain(HEARTBEAT_NO_TASK_PROCEDURE);
|
|
});
|
|
|
|
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
const systemPrompt = callArgs.systemPrompt;
|
|
|
|
expect(systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
|
|
expect(systemPrompt).toContain("fn_task_log");
|
|
expect(systemPrompt).toContain("fn_task_document_write");
|
|
expect(systemPrompt).toContain("Task Documents:");
|
|
});
|
|
|
|
it("timer task-scoped execution prompt is framed as autonomous heartbeat work", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1]![0]!;
|
|
expect(executionPrompt).toContain("## Wake Delta");
|
|
expect(executionPrompt).toContain("wake reason: timer");
|
|
expect(executionPrompt).toContain("autonomous heartbeat run");
|
|
});
|
|
|
|
it("identity agent without task gets soul in system prompt", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a CEO who prioritizes high-impact work" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
// Soul should be in the system prompt
|
|
expect(callArgs.systemPrompt).toContain("## Soul");
|
|
expect(callArgs.systemPrompt).toContain("I am a CEO who prioritizes high-impact work");
|
|
});
|
|
|
|
it("builds heartbeat system prompt with inline + file instructions plus soul and memory", async () => {
|
|
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-instr-"));
|
|
try {
|
|
writeFileSync(join(tmpRoot, "instructions.md"), "File-backed operating instruction", "utf-8");
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: undefined,
|
|
instructionsText: "Inline operating instruction",
|
|
instructionsPath: "instructions.md",
|
|
soul: "I am an autonomous agent",
|
|
memory: "Remember to prefer concrete actions",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
expect(callArgs.systemPrompt).toContain("Inline operating instruction");
|
|
expect(callArgs.systemPrompt).toContain("File-backed operating instruction");
|
|
expect(callArgs.systemPrompt).toContain("## Soul");
|
|
expect(callArgs.systemPrompt).toContain("## Agent Memory");
|
|
} finally {
|
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("agent WITHOUT identity (no soul, instructions, memory) still exits with no_assignment", async () => {
|
|
// Agent with empty strings should also exit gracefully
|
|
const store = createStoreWithAgentForExec({
|
|
taskId: undefined,
|
|
soul: "",
|
|
instructionsText: "",
|
|
memory: "",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
// Should NOT create a session for agents without identity
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
expect(result.resultJson).toEqual({ reason: "no_assignment" });
|
|
});
|
|
|
|
it("identity agent without task includes messaging tools when messageStore is available", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue([]),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
|
|
|
|
// Should have messaging tools when messageStore is available
|
|
expect(toolNames).toContain("fn_send_message");
|
|
expect(toolNames).toContain("fn_read_messages");
|
|
});
|
|
|
|
it("identity agent without task does NOT include messaging tools when messageStore is unavailable", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
|
|
|
|
// Should NOT have messaging tools when messageStore is not available
|
|
expect(toolNames).not.toContain("fn_send_message");
|
|
expect(toolNames).not.toContain("fn_read_messages");
|
|
});
|
|
|
|
it("identity agent without task fetches messages and includes them in prompt for timer trigger", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-notask-1",
|
|
fromId: "user-1",
|
|
content: "Please check the task board",
|
|
}),
|
|
createMessage({
|
|
id: "msg-notask-2",
|
|
fromId: "agent-5",
|
|
content: "Delegating FN-100 to you",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "timer",
|
|
triggerDetail: "scheduled",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
// Messages should be fetched for no-task runs too
|
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
|
// Messages should be marked as read after successful execution
|
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
|
|
|
// Verify execution prompt included the messages
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
expect(executionPrompt).toContain("Pending Messages:");
|
|
expect(executionPrompt).toContain("Please check the task board");
|
|
expect(executionPrompt).toContain("Delegating FN-100 to you");
|
|
});
|
|
});
|
|
|
|
describe("blocked-task heartbeat: runs through without early exit", () => {
|
|
it("invokes the model when task is blocked (no early exit)", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const taskDetail = {
|
|
id: "FN-BLOCKED",
|
|
title: "Blocked Task",
|
|
description: "Blocked task description",
|
|
prompt: "",
|
|
status: "queued",
|
|
blockedBy: "FN-DEP-1",
|
|
comments: [],
|
|
steeringComments: [],
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail;
|
|
|
|
mockTaskStore = createMockTaskStore({ getTask: vi.fn().mockResolvedValue(taskDetail) });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
// The heartbeat must fall through to model invocation — no early exit reason
|
|
expect(result.resultJson).not.toEqual(expect.objectContaining({ reason: "blocked" }));
|
|
expect(result.resultJson).not.toEqual(expect.objectContaining({ reason: "blocked_duplicate" }));
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
expect(mockSession.prompt).toHaveBeenCalled();
|
|
});
|
|
|
|
it("includes blockedBy in the prompt context when task is blocked", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-BLOCKED" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const taskDetail = {
|
|
id: "FN-BLOCKED",
|
|
title: "Blocked by dependency",
|
|
description: "Task blocked on FN-DEP-99",
|
|
prompt: "",
|
|
status: "queued",
|
|
blockedBy: "FN-DEP-99",
|
|
comments: [],
|
|
steeringComments: [],
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail;
|
|
|
|
mockTaskStore = createMockTaskStore({ getTask: vi.fn().mockResolvedValue(taskDetail) });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const promptCall = mockSession.prompt.mock.calls[0]?.[0] as string | undefined;
|
|
expect(promptCall).toBeDefined();
|
|
expect(promptCall).toContain("FN-DEP-99");
|
|
});
|
|
});
|
|
|
|
// ── Utility Lane Independence Regression ─────────────────────────────────────
|
|
// FN-1727: Heartbeat runs must execute on the control-plane (utility) lane
|
|
// and must NOT consume task-lane semaphore slots. This test proves that
|
|
// heartbeat execution completes successfully even when task execution
|
|
// slots are saturated (e.g., maxConcurrent: 0 or all slots occupied).
|
|
// The utility AI helper path must remain responsive under task-lane pressure.
|
|
describe("slot-saturation: heartbeat runs on utility lane independent of task-lane semaphore", () => {
|
|
it("executes heartbeat successfully while task-lane semaphore is saturated", async () => {
|
|
// Import AgentSemaphore directly to create a saturated slot fixture
|
|
const { AgentSemaphore } = await import("../concurrency.js");
|
|
|
|
// Create a semaphore with maxConcurrent=0 to simulate fully saturated state
|
|
// The defensive guard in AgentSemaphore.limit returns minimum 1, so we
|
|
// use a static limit of 0 and manually acquire to simulate saturation.
|
|
const taskLaneSemaphore = new AgentSemaphore(0);
|
|
|
|
// Acquire the single available slot to saturate task lanes
|
|
await taskLaneSemaphore.acquire();
|
|
|
|
// Verify the semaphore is saturated (no available slots)
|
|
expect(taskLaneSemaphore.availableCount).toBe(0);
|
|
expect(taskLaneSemaphore.activeCount).toBe(1);
|
|
|
|
// Create the heartbeat monitor (it does NOT receive the task-lane semaphore)
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
// Execute heartbeat while task lanes are saturated
|
|
// This MUST succeed because heartbeat runs on the utility lane
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
// CRITICAL ASSERTIONS:
|
|
// 1. Heartbeat completed successfully (proves it didn't wait for task-lane slot)
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
|
|
// 2. Agent session was created (proves execution proceeded)
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
|
|
// 3. Semaphore saturation is still held (proves heartbeat didn't consume task-lane slot)
|
|
expect(taskLaneSemaphore.activeCount).toBe(1);
|
|
|
|
// 4. Semaphore available count is still 0 (still saturated from task-lane perspective)
|
|
expect(taskLaneSemaphore.availableCount).toBe(0);
|
|
|
|
// Cleanup: release the task-lane slot
|
|
taskLaneSemaphore.release();
|
|
expect(taskLaneSemaphore.activeCount).toBe(0);
|
|
});
|
|
|
|
it("completes on_demand heartbeat while task-lane slots are fully occupied", async () => {
|
|
const { AgentSemaphore } = await import("../concurrency.js");
|
|
|
|
// Simulate multiple task-lane agents holding all slots
|
|
const taskLaneSemaphore = new AgentSemaphore(2);
|
|
|
|
// Saturate both slots with "task-lane agents"
|
|
await taskLaneSemaphore.acquire(); // Agent 1
|
|
await taskLaneSemaphore.acquire(); // Agent 2
|
|
|
|
expect(taskLaneSemaphore.availableCount).toBe(0);
|
|
|
|
// Now execute heartbeat - it should complete without waiting
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const startTime = Date.now();
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
const elapsed = Date.now() - startTime;
|
|
|
|
// Should complete quickly (not blocked by semaphore wait)
|
|
expect(elapsed).toBeLessThan(500);
|
|
|
|
// Heartbeat should succeed
|
|
expect(result.status).toBe("completed");
|
|
|
|
// Task-lane slots should remain occupied
|
|
expect(taskLaneSemaphore.activeCount).toBe(2);
|
|
|
|
// Cleanup
|
|
taskLaneSemaphore.release();
|
|
taskLaneSemaphore.release();
|
|
});
|
|
});
|
|
|
|
describe("executeHeartbeat - message processing", () => {
|
|
it("includes unread messages in prompt when woken by wake-on-message", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-1",
|
|
fromId: "agent-2",
|
|
fromType: "agent",
|
|
content: "Hello from agent-2",
|
|
createdAt: "2024-01-15T10:30:00.000Z",
|
|
}),
|
|
createMessage({
|
|
id: "msg-2",
|
|
fromId: "user-1",
|
|
content: "Hello from user",
|
|
createdAt: "2024-01-15T11:00:00.000Z",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
triggerDetail: "wake-on-message",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
|
|
|
// Verify execution prompt (passed to promptWithFallback) included the messages
|
|
// The execution prompt is passed to session.prompt by promptWithFallback mock
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
expect(executionPrompt).toContain("Pending Messages:");
|
|
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
|
|
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
|
|
// Task-scoped prompts must include Wake Delta + Heartbeat Procedure so
|
|
// the agent re-runs its procedure each tick instead of grinding on the
|
|
// assigned task (paperclip-parity).
|
|
expect(executionPrompt).toContain("## Wake Delta");
|
|
expect(executionPrompt).toContain("wake reason: message_received");
|
|
expect(executionPrompt).toContain("autonomous heartbeat run");
|
|
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
|
expect(executionPrompt).toContain("do not re-read PROMPT.md to advance it");
|
|
});
|
|
|
|
it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => {
|
|
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-procedure-"));
|
|
try {
|
|
const customProcedure = "## Custom CEO Procedure\n\n1. Review reports\n2. Update strategy\n3. Exit";
|
|
writeFileSync(join(tmpRoot, "MY-PROCEDURE.md"), customProcedure, "utf-8");
|
|
|
|
const store = createStoreWithAgentForExec({
|
|
heartbeatProcedurePath: "MY-PROCEDURE.md",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
|
|
// Custom procedure should appear; default constant should not.
|
|
expect(executionPrompt).toContain("## Custom CEO Procedure");
|
|
expect(executionPrompt).toContain("1. Review reports");
|
|
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
|
|
// Wake Delta still rendered.
|
|
expect(executionPrompt).toContain("## Wake Delta");
|
|
} finally {
|
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("falls back to default procedure when heartbeatProcedurePath is invalid (traversal)", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
heartbeatProcedurePath: "../escape.md",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
// Invalid path → fall back to the default constant.
|
|
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
|
});
|
|
|
|
it("does not include message section when no unread messages", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue([]),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
triggerDetail: "wake-on-message",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
|
|
// Verify prompt did NOT include pending messages section
|
|
// Note: without wake-on-message trigger, no messages are fetched
|
|
// so the prompt won't have the Pending Messages section at all
|
|
});
|
|
|
|
it("marks messages as read after successful heartbeat execution", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-1",
|
|
fromId: "agent-2",
|
|
content: "Hello from agent-2",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
triggerDetail: "wake-on-message",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
|
});
|
|
|
|
it("does not mark messages as read on failed heartbeat execution", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Execution failed"));
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-1",
|
|
fromId: "agent-2",
|
|
content: "Hello from agent-2",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
triggerDetail: "wake-on-message",
|
|
});
|
|
|
|
expect(result.status).toBe("failed");
|
|
expect(messageStore.markAllAsRead).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches messages for timer-triggered runs when messageStore is available", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-1",
|
|
fromId: "agent-2",
|
|
content: "Reminder about task FN-001",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
// Use a timer trigger (not wake-on-message)
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "timer",
|
|
triggerDetail: "scheduled",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
// Messages should be fetched even for timer triggers
|
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
|
// Messages should be marked as read after successful execution
|
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
|
|
|
// Verify execution prompt included the messages
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
expect(executionPrompt).toContain("Pending Messages:");
|
|
expect(executionPrompt).toContain("Reminder about task FN-001");
|
|
});
|
|
|
|
it("fetches messages for assignment-triggered runs when messageStore is available", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-assign-1",
|
|
fromId: "user-1",
|
|
content: "Please work on this task",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
// Use an assignment trigger
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "assignment",
|
|
triggerDetail: "task-assigned",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
|
|
|
// Verify execution prompt included the messages
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
expect(executionPrompt).toContain("Pending Messages:");
|
|
expect(executionPrompt).toContain("Please work on this task");
|
|
});
|
|
|
|
it("fetches messages for on-demand runs without wake-on-message trigger", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-od-1",
|
|
fromId: "agent-3",
|
|
content: "Status update: task FN-002 is complete",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
// Use on-demand trigger without wake-on-message
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
|
|
|
// Verify execution prompt included the messages
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
expect(executionPrompt).toContain("Pending Messages:");
|
|
expect(executionPrompt).toContain("Status update: task FN-002 is complete");
|
|
});
|
|
|
|
it("still fetches messages for wake-on-message triggers", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const messages = [
|
|
createMessage({
|
|
id: "msg-wom-1",
|
|
fromId: "agent-2",
|
|
content: "Hello from agent-2",
|
|
}),
|
|
];
|
|
|
|
const messageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
getInbox: vi.fn().mockReturnValue(messages),
|
|
markAllAsRead: vi.fn(),
|
|
} as unknown as MessageStore;
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
triggerDetail: "wake-on-message",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
|
|
|
// Verify execution prompt included the messages
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
expect(promptCalls.length).toBeGreaterThan(0);
|
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
|
expect(executionPrompt).toContain("Pending Messages:");
|
|
expect(executionPrompt).toContain("Hello from agent-2");
|
|
});
|
|
|
|
describe("end-to-end message flow", () => {
|
|
it("proves wake-on-message can surface a user message and send a linked reply", async () => {
|
|
const messages: Map<string, Message[]> = new Map();
|
|
let messageCounter = 0;
|
|
|
|
const fakeMessageStore = {
|
|
setMessageToAgentHook: vi.fn(),
|
|
sendMessage: vi.fn((input: Omit<Message, "id" | "read" | "createdAt" | "updatedAt">) => {
|
|
const id = `msg-${++messageCounter}`;
|
|
const createdAt = new Date().toISOString();
|
|
const msg: Message = {
|
|
id,
|
|
...input,
|
|
read: false,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
};
|
|
|
|
const key = `${input.toId}:${input.toType}`;
|
|
const inbox = messages.get(key) || [];
|
|
inbox.push(msg);
|
|
messages.set(key, inbox);
|
|
return msg;
|
|
}),
|
|
getInbox: vi.fn((participantId: string, participantType: string, opts?: { read?: boolean }) => {
|
|
const key = `${participantId}:${participantType}`;
|
|
const inbox = messages.get(key) || [];
|
|
if (opts?.read === false) return inbox.filter((message) => !message.read);
|
|
return inbox;
|
|
}),
|
|
getMailbox: vi.fn((participantId: string, participantType: string) => {
|
|
const key = `${participantId}:${participantType}`;
|
|
const inbox = messages.get(key) || [];
|
|
return { unreadCount: inbox.filter((message) => !message.read).length, messages: inbox };
|
|
}),
|
|
markAllAsRead: vi.fn((participantId: string, participantType: string) => {
|
|
const key = `${participantId}:${participantType}`;
|
|
const inbox = messages.get(key) || [];
|
|
inbox.forEach((message) => {
|
|
message.read = true;
|
|
});
|
|
messages.set(key, inbox);
|
|
}),
|
|
} as unknown as MessageStore;
|
|
|
|
const inboundFromUser = fakeMessageStore.sendMessage({
|
|
fromId: "dashboard",
|
|
fromType: "user",
|
|
toId: "agent-beta",
|
|
toType: "agent",
|
|
content: "Can you post a status update?",
|
|
type: "user-to-agent",
|
|
});
|
|
|
|
const store = createStoreWithAgentForExec({
|
|
id: "agent-beta",
|
|
name: "Agent Beta",
|
|
state: "active",
|
|
taskId: "FN-001",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({
|
|
store,
|
|
messageStore: fakeMessageStore,
|
|
taskStore: mockTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-beta",
|
|
source: "on_demand",
|
|
triggerDetail: "wake-on-message",
|
|
});
|
|
|
|
expect(result.status).toBe("completed");
|
|
|
|
const promptCalls = mockSession.prompt.mock.calls;
|
|
const executionPrompt = promptCalls[promptCalls.length - 1]?.[0] as string;
|
|
expect(executionPrompt).toContain("Pending Messages:");
|
|
expect(executionPrompt).toContain(`[id: ${inboundFromUser.id}]`);
|
|
expect(executionPrompt).toContain("dashboard");
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
|
const sendMessageTool = callArgs.customTools?.find((tool: { name: string }) => tool.name === "fn_send_message");
|
|
expect(sendMessageTool).toBeDefined();
|
|
|
|
for (const [index, alias] of ["dashboard", "user:dashboard", "User: user:dashboard"].entries()) {
|
|
await sendMessageTool!.execute(
|
|
`tool-call-${index}`,
|
|
{
|
|
to_id: alias,
|
|
content: `Status: I am on it. (${index})`,
|
|
type: "agent-to-user",
|
|
reply_to_message_id: inboundFromUser.id,
|
|
},
|
|
undefined,
|
|
undefined,
|
|
{} as any,
|
|
);
|
|
}
|
|
|
|
const dashboardInbox = fakeMessageStore.getInbox("dashboard", "user");
|
|
for (const index of [0, 1, 2]) {
|
|
const linkedReply = dashboardInbox.find((message) => message.content === `Status: I am on it. (${index})`);
|
|
expect(linkedReply?.metadata).toEqual({ replyTo: { messageId: inboundFromUser.id } });
|
|
}
|
|
|
|
monitor.stop();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("executeHeartbeat - inbox selection", () => {
|
|
const makeInboxSelection = (
|
|
taskId: string,
|
|
priority: "in_progress" | "todo" | "blocked" = "todo",
|
|
sourceMetadata?: Record<string, unknown>,
|
|
) => {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
task: {
|
|
id: taskId,
|
|
description: `Inbox task ${taskId}`,
|
|
column: priority === "in_progress" ? "in-progress" : "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...(sourceMetadata ? { sourceMetadata } : {}),
|
|
},
|
|
priority,
|
|
reason: `selected:${priority}`,
|
|
} as any;
|
|
};
|
|
|
|
it("when agent has no taskId, inbox selects a todo task and assigns it", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
|
|
mockTaskStore = createMockTaskStore({
|
|
selectNextTaskForAgent,
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-INBOX",
|
|
title: "Inbox Task",
|
|
description: "Inbox-selected task",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail),
|
|
});
|
|
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "executor" });
|
|
expect(store.assignTask).toHaveBeenCalledWith("agent-001", "FN-INBOX", expect.objectContaining({ agentId: "agent-001" }));
|
|
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-INBOX");
|
|
});
|
|
|
|
it("explicit taskId override takes precedence over inbox selection", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
|
|
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
|
|
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
taskId: "FN-EXPLICIT",
|
|
});
|
|
|
|
expect(selectNextTaskForAgent).not.toHaveBeenCalled();
|
|
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-EXPLICIT");
|
|
});
|
|
|
|
it("agent's existing taskId takes precedence over inbox selection", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-EXISTING" });
|
|
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo"));
|
|
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
|
|
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(selectNextTaskForAgent).not.toHaveBeenCalled();
|
|
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-EXISTING");
|
|
});
|
|
|
|
it("allows non-executor inbox selection when override metadata is present", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined, role: "reviewer" });
|
|
const selectNextTaskForAgent = vi.fn().mockResolvedValue(
|
|
makeInboxSelection("FN-INBOX", "todo", { executorRoleOverride: true }),
|
|
);
|
|
mockTaskStore = createMockTaskStore({
|
|
selectNextTaskForAgent,
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-INBOX",
|
|
title: "Inbox Task",
|
|
description: "Inbox-selected task",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
sourceMetadata: { executorRoleOverride: true },
|
|
} as unknown as TaskDetail),
|
|
});
|
|
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "reviewer" });
|
|
expect(store.assignTask).toHaveBeenCalledWith("agent-001", "FN-INBOX", expect.objectContaining({ agentId: "agent-001" }));
|
|
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "inbox_selected", taskId: "FN-INBOX" }));
|
|
});
|
|
|
|
it("when inbox returns null, heartbeat completes with no_assignment", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
const selectNextTaskForAgent = vi.fn().mockResolvedValue(null);
|
|
mockTaskStore = createMockTaskStore({ selectNextTaskForAgent });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "executor" });
|
|
expect(result.resultJson).toEqual({ reason: "no_assignment" });
|
|
});
|
|
|
|
it("records inbox selection metadata in resultJson", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
mockTaskStore = createMockTaskStore({
|
|
selectNextTaskForAgent: vi.fn().mockResolvedValue(makeInboxSelection("FN-INBOX", "todo")),
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-INBOX",
|
|
title: "Inbox Task",
|
|
description: "Inbox-selected task",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail),
|
|
});
|
|
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result.resultJson).toEqual(expect.objectContaining({
|
|
reason: "inbox_selected",
|
|
priority: "todo",
|
|
taskId: "FN-INBOX",
|
|
}));
|
|
});
|
|
|
|
it("supports in-progress inbox selections before todo", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
mockTaskStore = createMockTaskStore({
|
|
selectNextTaskForAgent: vi.fn().mockResolvedValue(makeInboxSelection("FN-RESUME", "in_progress")),
|
|
getTask: vi.fn().mockResolvedValue({
|
|
id: "FN-RESUME",
|
|
title: "Resume task",
|
|
description: "Resume in-progress work",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail),
|
|
});
|
|
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-RESUME");
|
|
expect(result.resultJson).toEqual(expect.objectContaining({
|
|
reason: "inbox_selected",
|
|
priority: "in_progress",
|
|
taskId: "FN-RESUME",
|
|
}));
|
|
});
|
|
|
|
it("gracefully skips inbox selection when checkoutTask throws", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: undefined });
|
|
const selectNextTaskForAgent = vi.fn().mockResolvedValue(makeInboxSelection("FN-CHECKOUT", "todo"));
|
|
const checkoutTask = vi.fn().mockRejectedValue(new Error("Task is already checked out"));
|
|
mockTaskStore = createMockTaskStore({
|
|
selectNextTaskForAgent,
|
|
checkoutTask: checkoutTask as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "executor" });
|
|
expect(checkoutTask).toHaveBeenCalledWith("FN-CHECKOUT", "agent-001", expect.objectContaining({ agentId: "agent-001" }));
|
|
expect(result.resultJson).toEqual({ reason: "no_assignment" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("execution", () => {
|
|
it("no-task system prompt does not reference fn_task_log or task_document tools", () => {
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_log");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_document_write");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_document_read");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("task_document");
|
|
});
|
|
|
|
it("no-task system prompt references only available tools", () => {
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_task_create");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_list_agents");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_delegate_task");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_send_message");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_read_messages");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_memory_search");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_memory_get");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_memory_append");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("fn_heartbeat_done");
|
|
});
|
|
|
|
it("no-task heartbeat procedure aligns with ambient tools", () => {
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("fn_task_log");
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("fn_task_document_write");
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("fn_task_create");
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("fn_delegate_task");
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("fn_memory_append");
|
|
});
|
|
|
|
it("task-scoped system prompt still references fn_task_log and fn_task_document tools", () => {
|
|
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_log");
|
|
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_document_write");
|
|
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_document tools");
|
|
});
|
|
|
|
it("both prompts include memory boundaries section", () => {
|
|
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("## Memory Boundaries");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("## Memory Boundaries");
|
|
});
|
|
|
|
it("both prompts instruct replies to include reply_to_message_id", () => {
|
|
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("reply_to_message_id");
|
|
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("reply_to_message_id");
|
|
});
|
|
|
|
it("both heartbeat procedures prioritize inbox processing before wake delta", () => {
|
|
expect(HEARTBEAT_PROCEDURE).toContain("process unread/pending messages before any other action");
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("process unread/pending messages before any other action");
|
|
expect(HEARTBEAT_PROCEDURE.indexOf("**Inbox**")).toBeLessThan(HEARTBEAT_PROCEDURE.indexOf("**Wake delta**"));
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Inbox**")).toBeLessThan(HEARTBEAT_NO_TASK_PROCEDURE.indexOf("**Wake delta**"));
|
|
});
|
|
|
|
it("heartbeat procedures include scope-discipline guidance", () => {
|
|
expect(HEARTBEAT_PROCEDURE).toContain("executor-class");
|
|
expect(HEARTBEAT_PROCEDURE).toContain("blocked");
|
|
expect(HEARTBEAT_PROCEDURE).toContain("coordination-class");
|
|
expect(HEARTBEAT_PROCEDURE).toContain("do not re-read PROMPT.md to advance it");
|
|
expect(HEARTBEAT_PROCEDURE).toContain("avoid re-planning it");
|
|
|
|
const inboxIndex = HEARTBEAT_PROCEDURE.indexOf("**Inbox**");
|
|
const wakeDeltaIndex = HEARTBEAT_PROCEDURE.indexOf("**Wake delta**");
|
|
const classifyIndex = HEARTBEAT_PROCEDURE.indexOf("**Classify the bound task**");
|
|
const selfCheckIndex = HEARTBEAT_PROCEDURE.indexOf("**Per-tick self-check**");
|
|
const exitIndex = HEARTBEAT_PROCEDURE.indexOf("fn_heartbeat_done");
|
|
expect(classifyIndex).toBeGreaterThan(inboxIndex);
|
|
expect(classifyIndex).toBeGreaterThan(wakeDeltaIndex);
|
|
expect(selfCheckIndex).toBeGreaterThan(classifyIndex);
|
|
expect(selfCheckIndex).toBeLessThan(exitIndex);
|
|
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).toContain("coordination-class");
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("executor-class");
|
|
expect(HEARTBEAT_NO_TASK_PROCEDURE).not.toContain("do not re-read PROMPT.md to advance it");
|
|
});
|
|
|
|
it("no-task system prompt processing messages section does not reference fn_task_log", () => {
|
|
const processingMessagesSection = HEARTBEAT_NO_TASK_SYSTEM_PROMPT.split("## Processing Messages")[1] ?? "";
|
|
expect(processingMessagesSection).not.toContain("fn_task_log");
|
|
});
|
|
|
|
it("creates session with enriched system prompt and expected tools", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
soul: "Act like a practical teammate who prioritizes clarity.",
|
|
memory: "Recent runs found flaky tests in integration suites.",
|
|
instructionsText: "Always log blockers with actionable next steps.",
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
expect(callArgs.cwd).toBe("/tmp/test");
|
|
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
|
|
expect(callArgs.systemPrompt).toContain("## Soul");
|
|
expect(callArgs.systemPrompt).toContain("Act like a practical teammate who prioritizes clarity.");
|
|
expect(callArgs.systemPrompt).toContain("## Agent Memory");
|
|
expect(callArgs.systemPrompt).toContain("Recent runs found flaky tests in integration suites.");
|
|
expect(callArgs.systemPrompt).toContain("Always log blockers with actionable next steps.");
|
|
expect(callArgs.systemPrompt).toContain("## Project Memory");
|
|
expect(callArgs.systemPrompt).toContain("fn_memory_search");
|
|
expect(callArgs.systemPrompt).toContain("fn_task_log");
|
|
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
|
|
expect(callArgs.tools).toBe("coding");
|
|
// fn_get_agent_config, fn_update_agent_config, fn_agent_create, fn_agent_delete, fn_read_evaluations, fn_update_identity,
|
|
// fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
|
|
expect(callArgs.customTools).toHaveLength(17);
|
|
expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
|
|
expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
|
|
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
|
|
expect(callArgs.customTools![3]!.name).toBe("fn_task_document_read");
|
|
expect(callArgs.customTools![4]!.name).toBe("fn_list_agents");
|
|
expect(callArgs.customTools![5]!.name).toBe("fn_delegate_task");
|
|
expect(callArgs.customTools![6]!.name).toBe("fn_get_agent_config");
|
|
expect(callArgs.customTools![7]!.name).toBe("fn_update_agent_config");
|
|
expect(callArgs.customTools![8]!.name).toBe("fn_agent_create");
|
|
expect(callArgs.customTools![9]!.name).toBe("fn_agent_delete");
|
|
expect(callArgs.customTools![10]!.name).toBe("fn_read_evaluations");
|
|
expect(callArgs.customTools![11]!.name).toBe("fn_update_identity");
|
|
expect(callArgs.customTools![12]!.name).toBe("fn_web_fetch");
|
|
expect(callArgs.customTools![13]!.name).toBe("fn_memory_search");
|
|
expect(callArgs.customTools![14]!.name).toBe("fn_memory_get");
|
|
expect(callArgs.customTools![15]!.name).toBe("fn_memory_append");
|
|
// fn_heartbeat_done is last (terminal tool)
|
|
expect(callArgs.customTools![16]!.name).toBe("fn_heartbeat_done");
|
|
});
|
|
|
|
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {
|
|
const rootDir = mkdtempSync(join(tmpdir(), "heartbeat-workspace-memory-"));
|
|
mkdirSync(join(rootDir, ".fusion", "agent-memory", "agent-001"), { recursive: true });
|
|
writeFileSync(
|
|
join(rootDir, ".fusion", "agent-memory", "agent-001", "MEMORY.md"),
|
|
"workspace memory for heartbeat",
|
|
"utf-8",
|
|
);
|
|
|
|
try {
|
|
const store = createStoreWithAgentForExec({
|
|
memory: "",
|
|
instructionsText: undefined,
|
|
instructionsPath: undefined,
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
expect(callArgs.systemPrompt).toContain("## Agent Memory");
|
|
expect(callArgs.systemPrompt).toContain("workspace memory for heartbeat");
|
|
|
|
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
|
|
expect(executionPrompt).toMatch(/- memory: loaded \(\d+ chars, sha256:[a-f0-9]{8}, source: workspace\)/);
|
|
} finally {
|
|
rmSync(rootDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("includes memory instructions even when agent has no custom instructions", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
soul: undefined,
|
|
memory: undefined,
|
|
instructionsText: undefined,
|
|
instructionsPath: undefined,
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
|
|
expect(callArgs.systemPrompt).toContain("## Project Memory");
|
|
});
|
|
|
|
it("includes markdown instructions files plus soul in heartbeat system prompts", async () => {
|
|
const rootDir = mkdtempSync(join(tmpdir(), "heartbeat-agent-instructions-"));
|
|
writeFileSync(
|
|
join(rootDir, "heartbeat-agent.md"),
|
|
"# Heartbeat Playbook\n\nCheck messages first, then create focused follow-up tasks.",
|
|
);
|
|
|
|
try {
|
|
const store = createStoreWithAgentForExec({
|
|
instructionsPath: "heartbeat-agent.md",
|
|
soul: "Operate like a calm, systems-minded operator.",
|
|
});
|
|
const taskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({ memoryEnabled: false }),
|
|
} as Partial<TaskStore>);
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
|
|
expect(callArgs.systemPrompt).toContain("## Soul");
|
|
expect(callArgs.systemPrompt).toContain("Operate like a calm, systems-minded operator.");
|
|
expect(callArgs.systemPrompt).toContain("# Heartbeat Playbook");
|
|
expect(callArgs.systemPrompt).toContain("create focused follow-up tasks");
|
|
} finally {
|
|
rmSync(rootDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("omits memory tools and instructions when project memory is disabled", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const taskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({ memoryEnabled: false }),
|
|
} as Partial<TaskStore>);
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp/test" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
|
|
expect(callArgs.systemPrompt).not.toContain("## Project Memory");
|
|
expect(toolNames).not.toContain("fn_memory_search");
|
|
expect(toolNames).not.toContain("fn_memory_get");
|
|
expect(toolNames).not.toContain("fn_memory_append");
|
|
});
|
|
|
|
it("wires session memory tools to read agent long-term, dreams, and daily layers", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
name: "CEO",
|
|
memory: "Prioritize roadmap sequencing and delegate implementation follow-ups.",
|
|
});
|
|
const taskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({ memoryBackendType: "file" }),
|
|
} as Partial<TaskStore>);
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp/test" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
const memorySearch = callArgs.customTools!.find((tool: any) => tool.name === "fn_memory_search") as any;
|
|
const memoryGet = callArgs.customTools!.find((tool: any) => tool.name === "fn_memory_get") as any;
|
|
const memoryAppend = callArgs.customTools!.find((tool: any) => tool.name === "fn_memory_append") as any;
|
|
|
|
expect(memorySearch).toBeDefined();
|
|
expect(memoryGet).toBeDefined();
|
|
expect(memoryAppend).toBeDefined();
|
|
|
|
await memoryAppend.execute("call-append-dream", {
|
|
scope: "agent",
|
|
layer: "daily",
|
|
content: "- Daily delegation note from heartbeat test",
|
|
}, undefined, undefined, undefined);
|
|
appendFileSync(
|
|
"/tmp/test/.fusion/agent-memory/agent-001/DREAMS.md",
|
|
"\n- Dream delegation theme from heartbeat test\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const dreamsResult = await memorySearch.execute("call-search-1", {
|
|
query: "dream delegation theme",
|
|
limit: 5,
|
|
}, undefined, undefined, undefined);
|
|
const dailyResult = await memorySearch.execute("call-search-2", {
|
|
query: "daily delegation note",
|
|
limit: 5,
|
|
}, undefined, undefined, undefined);
|
|
|
|
expect(dreamsResult.content[0].text).toContain(".fusion/agent-memory/agent-001/DREAMS.md");
|
|
expect(dailyResult.content[0].text).toContain(".fusion/agent-memory/agent-001/");
|
|
|
|
const dreamsRead = await memoryGet.execute("call-get-1", {
|
|
path: ".fusion/agent-memory/agent-001/DREAMS.md",
|
|
startLine: 1,
|
|
lineCount: 40,
|
|
}, undefined, undefined, undefined);
|
|
|
|
expect(dreamsRead.content[0].text).toContain("Dream delegation theme from heartbeat test");
|
|
});
|
|
|
|
it("includes document tools in heartbeat session", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
const toolNames = callArgs.customTools!.map((t: any) => t.name);
|
|
expect(toolNames).toContain("fn_task_document_write");
|
|
expect(toolNames).toContain("fn_task_document_read");
|
|
});
|
|
|
|
it("fn_heartbeat_done is the terminal tool (last in array)", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
const toolNames = callArgs.customTools!.map((t: any) => t.name);
|
|
// fn_heartbeat_done should be last for stable terminal signaling
|
|
expect(toolNames[toolNames.length - 1]).toBe("fn_heartbeat_done");
|
|
});
|
|
|
|
it("calls promptWithFallback with task context", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment", triggerDetail: "new task assigned" });
|
|
|
|
expect(mockSession.prompt).toHaveBeenCalledOnce();
|
|
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
|
|
expect(promptArg).toContain("agent-001");
|
|
expect(promptArg).toContain("Test Task");
|
|
expect(promptArg).toContain("assignment");
|
|
expect(promptArg).toContain("new task assigned");
|
|
expect(promptArg).toContain("PROMPT.md");
|
|
});
|
|
|
|
it("includes triggering comment context in execution prompt when comment IDs are provided", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
mockTaskStore.getTask = vi.fn().mockResolvedValue({
|
|
id: "FN-001",
|
|
title: "Test Task",
|
|
description: "Test task description",
|
|
prompt: "# Prompt",
|
|
comments: [{ id: "c-1", author: "user", text: "Please cover edge cases", createdAt: "2026-01-01T00:00:00.000Z" }],
|
|
steeringComments: [{ id: "s-1", author: "agent", text: "Investigating blocker", createdAt: "2026-01-01T00:01:00.000Z" }],
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
triggeringCommentIds: ["c-1", "s-1"],
|
|
triggeringCommentType: "steering",
|
|
});
|
|
|
|
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
|
|
expect(promptArg).toContain("You were woken because of new comments on this task");
|
|
expect(promptArg).toContain("Please cover edge cases");
|
|
expect(promptArg).toContain("Investigating blocker");
|
|
});
|
|
|
|
it("keeps standard prompt when no triggering comments are provided", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
const promptArg = mockSession.prompt.mock.calls[0]![0] as string;
|
|
expect(promptArg).not.toContain("You were woken because of new comments on this task");
|
|
expect(promptArg).not.toContain("New comments since last run:");
|
|
});
|
|
|
|
it("completes run with status completed on successful execution", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("completed");
|
|
// Agent state should be set back to active
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
|
|
// Session should be disposed
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses explicit taskId override instead of agent.taskId", async () => {
|
|
const store = createStoreWithAgentForExec({ taskId: "FN-DEFAULT" });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
// Override getTask to return a different task
|
|
mockTaskStore.getTask = vi.fn().mockResolvedValue({
|
|
id: "FN-OVERRIDE",
|
|
title: "Override Task",
|
|
description: "Override description",
|
|
prompt: "",
|
|
steps: [],
|
|
column: "todo",
|
|
dependencies: [],
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as unknown as TaskDetail);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "on_demand",
|
|
taskId: "FN-OVERRIDE",
|
|
});
|
|
|
|
// Should have fetched the override task
|
|
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-OVERRIDE");
|
|
// fn_task_log tool should use the override task ID
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
const taskLogTool = callArgs.customTools![1]!;
|
|
expect(taskLogTool.name).toBe("fn_task_log");
|
|
});
|
|
|
|
it("passes runtime model as primary and execution settings model as fallback", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
runtimeConfig: { model: "anthropic/claude-sonnet-4-5" },
|
|
});
|
|
mockTaskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
executionProvider: "openai",
|
|
executionModelId: "gpt-4.1",
|
|
}),
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
expect(callArgs.defaultProvider).toBe("anthropic");
|
|
expect(callArgs.defaultModelId).toBe("claude-sonnet-4-5");
|
|
expect(callArgs.fallbackProvider).toBe("openai");
|
|
expect(callArgs.fallbackModelId).toBe("gpt-4.1");
|
|
});
|
|
|
|
it("passes undefined model when runtimeConfig has no model", async () => {
|
|
const store = createStoreWithAgentForExec({ runtimeConfig: {} });
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
|
expect(callArgs.defaultProvider).toBeUndefined();
|
|
expect(callArgs.defaultModelId).toBeUndefined();
|
|
});
|
|
|
|
it("persists contextSnapshot on run records", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "assignment",
|
|
triggerDetail: "task-assigned",
|
|
triggeringCommentIds: ["comment-1"],
|
|
triggeringCommentType: "task",
|
|
contextSnapshot: {
|
|
wakeReason: "assignment",
|
|
triggerDetail: "task-assigned",
|
|
taskId: "FN-001",
|
|
},
|
|
});
|
|
|
|
expect(result.contextSnapshot).toEqual({
|
|
wakeReason: "assignment",
|
|
triggerDetail: "task-assigned",
|
|
taskId: "FN-001",
|
|
triggeringCommentIds: ["comment-1"],
|
|
triggeringCommentType: "task",
|
|
});
|
|
});
|
|
|
|
it("persists automation source recovery context on run records", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "automation",
|
|
triggerDetail: "self-healing durable-agent transient recovery",
|
|
contextSnapshot: {
|
|
selfHealing: {
|
|
reason: "transient-error",
|
|
attempt: 1,
|
|
source: "durable-agent-transient-error-recovery",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.contextSnapshot).toEqual(
|
|
expect.objectContaining({
|
|
selfHealing: {
|
|
reason: "transient-error",
|
|
attempt: 1,
|
|
source: "durable-agent-transient-error-recovery",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("records agent logs, context taskId, and stdoutExcerpt for successful runs", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
|
mockTaskStore = createMockTaskStore({ appendAgentLog });
|
|
|
|
const mockSession = createMockAgentSession();
|
|
let onText: ((delta: string) => void) | undefined;
|
|
let onToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
|
|
let onToolEnd: ((name: string, isError: boolean, result?: unknown) => void) | undefined;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
onText = opts.onText;
|
|
onToolStart = opts.onToolStart;
|
|
onToolEnd = opts.onToolEnd;
|
|
return { session: mockSession as any };
|
|
});
|
|
|
|
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
|
onText?.("Heartbeat produced visible output");
|
|
onToolStart?.("read", { path: "README.md" });
|
|
onToolEnd?.("read", false, "done");
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "Heartbeat produced visible output", "text", undefined, "executor");
|
|
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", "README.md", "executor");
|
|
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", "done", "executor");
|
|
expect(result.contextSnapshot?.taskId).toBe("FN-001");
|
|
expect(result.stdoutExcerpt).toContain("Heartbeat produced visible output");
|
|
});
|
|
});
|
|
|
|
describe("fn_heartbeat_done tool", () => {
|
|
it("captures summary from fn_heartbeat_done in resultJson", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
let capturedDoneTool: any;
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
// fn_heartbeat_done is last in the customTools array (index 4)
|
|
capturedDoneTool = opts.customTools[opts.customTools.length - 1];
|
|
return { session: mockSession as any };
|
|
});
|
|
|
|
// Simulate: when prompt is called, invoke the fn_heartbeat_done tool
|
|
mockSession.prompt = vi.fn().mockImplementation(async (prompt: string) => {
|
|
// Simulate the agent calling fn_heartbeat_done
|
|
const result = await capturedDoneTool.execute("call-1", { summary: "Checked task, all good" });
|
|
expect(result.content[0].text).toContain("Heartbeat complete");
|
|
expect(result.content[0].text).toContain("Checked task, all good");
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const run = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(run.resultJson).toBeDefined();
|
|
expect((run.resultJson as any).summary).toBe("Checked task, all good");
|
|
});
|
|
|
|
it("works without summary in fn_heartbeat_done", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
let capturedDoneTool: any;
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedDoneTool = opts.customTools[opts.customTools.length - 1];
|
|
return { session: mockSession as any };
|
|
});
|
|
|
|
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
|
await capturedDoneTool.execute("call-1", {});
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const run = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(run.resultJson).toBeDefined();
|
|
expect((run.resultJson as any).summary).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("fn_task_create tool", () => {
|
|
it("creates a task in the store when fn_task_create tool is called", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
let capturedCreateTool: any;
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
capturedCreateTool = opts.customTools[0]; // fn_task_create
|
|
return { session: mockSession as any };
|
|
});
|
|
|
|
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
|
await capturedCreateTool.execute("call-1", { description: "Follow-up task" });
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(mockTaskStore.createTask).toHaveBeenCalledWith({
|
|
description: "Follow-up task",
|
|
dependencies: undefined,
|
|
column: "triage",
|
|
source: {
|
|
sourceType: "agent_heartbeat",
|
|
sourceAgentId: "agent-001",
|
|
sourceRunId: undefined,
|
|
},
|
|
}, expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("completes run as failed when createFnAgent throws", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("Model unavailable"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("failed");
|
|
expect(result.stderrExcerpt).toContain("Model unavailable");
|
|
// Agent state should be set to error
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "error");
|
|
});
|
|
|
|
it("fails soft on timer heartbeat when model provider credentials are unavailable", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toMatchObject({
|
|
reason: "heartbeat_model_unavailable",
|
|
source: "timer",
|
|
detail: expect.stringContaining("No API key for provider: anthropic"),
|
|
});
|
|
expect(result.stderrExcerpt).toContain("No API key for provider: anthropic");
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
|
|
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
|
|
});
|
|
|
|
it.each(["on_demand", "assignment"] as const)("pauses on %s heartbeat when model provider credentials are unavailable", async (source) => {
|
|
const store = createStoreWithAgentForExec();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toMatchObject({
|
|
reason: "heartbeat_model_unavailable",
|
|
source,
|
|
actionRequired: true,
|
|
detail: expect.stringContaining("Configure credentials for provider \"anthropic\""),
|
|
});
|
|
expect(result.stderrExcerpt).toContain("No API key for provider: anthropic");
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
|
|
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", {
|
|
pauseReason: "heartbeat-model-unavailable",
|
|
lastError: expect.stringContaining("No API key for provider: anthropic"),
|
|
});
|
|
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
|
|
});
|
|
|
|
it("keeps timer-triggered credential failures in recoverable state across consecutive wakeups", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const first = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
const second = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
for (const run of [first, second]) {
|
|
expect(run.status).toBe("completed");
|
|
expect(run.resultJson).toMatchObject({
|
|
reason: "heartbeat_model_unavailable",
|
|
source: "timer",
|
|
detail: expect.stringContaining("No API key for provider: anthropic"),
|
|
});
|
|
expect(run.stderrExcerpt).toContain("No API key for provider: anthropic");
|
|
}
|
|
|
|
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
|
|
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
|
|
});
|
|
|
|
it("keeps non-timer credential failures recoverable on consecutive wakeups", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const first = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
|
|
const second = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
|
|
|
|
expect(first.status).toBe("completed");
|
|
expect(first.resultJson).toMatchObject({
|
|
reason: "heartbeat_model_unavailable",
|
|
source: "assignment",
|
|
actionRequired: true,
|
|
});
|
|
expect(second.status).toBe("completed");
|
|
expect(second.resultJson).toMatchObject({
|
|
reason: "heartbeat_model_unavailable",
|
|
source: "assignment",
|
|
actionRequired: true,
|
|
});
|
|
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
|
|
});
|
|
|
|
it("completes run as failed when promptWithFallback throws", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Prompt failed"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.status).toBe("failed");
|
|
expect(result.stderrExcerpt).toContain("Prompt failed");
|
|
// Session should still be disposed in finally block
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
// Agent should be untracked
|
|
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
|
|
});
|
|
|
|
it("flushes AgentLogger on execution failure", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
const flushSpy = vi.spyOn(AgentLogger.prototype, "flush").mockResolvedValue(undefined);
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Prompt failed"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(flushSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("flushes AgentLogger when session creation fails", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const flushSpy = vi.spyOn(AgentLogger.prototype, "flush").mockResolvedValue(undefined);
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("Model unavailable"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(flushSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("concurrency", () => {
|
|
it("serializes concurrent executeHeartbeat calls for the same agent", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
let promptCallCount = 0;
|
|
|
|
// Make prompt take some time to ensure overlap
|
|
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
|
promptCallCount++;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
});
|
|
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
// We need getRunDetail to return different runs for each call
|
|
let runCount = 0;
|
|
const concurrentSavedRuns: Map<string, AgentHeartbeatRun> = new Map();
|
|
(store.startHeartbeatRun as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
runCount++;
|
|
return {
|
|
id: `run-${runCount}`,
|
|
agentId: "agent-001",
|
|
startedAt: new Date().toISOString(),
|
|
endedAt: null,
|
|
status: "active",
|
|
} as AgentHeartbeatRun;
|
|
});
|
|
(store.saveRun as ReturnType<typeof vi.fn>).mockImplementation(async (run: AgentHeartbeatRun) => {
|
|
concurrentSavedRuns.set(run.id, run);
|
|
});
|
|
(store.getRunDetail as ReturnType<typeof vi.fn>).mockImplementation(async (_agentId: string, runId: string) => {
|
|
return concurrentSavedRuns.get(runId) ?? {
|
|
id: runId,
|
|
agentId: "agent-001",
|
|
startedAt: new Date().toISOString(),
|
|
endedAt: new Date().toISOString(),
|
|
status: "completed" as const,
|
|
};
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
// Fire two concurrent executions
|
|
const [result1, result2] = await Promise.all([
|
|
monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }),
|
|
monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }),
|
|
]);
|
|
|
|
// Both should complete
|
|
expect(result1).toBeDefined();
|
|
expect(result2).toBeDefined();
|
|
// Both should have called prompt (serialized, not concurrent)
|
|
expect(promptCallCount).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("usage tracking", () => {
|
|
it("records estimated output tokens in usageJson", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
let onTextCallback: ((delta: string) => void) | undefined;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
onTextCallback = opts.onText;
|
|
return { session: mockSession as any };
|
|
});
|
|
|
|
// Simulate text output
|
|
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
|
// Simulate 100 chars of output (roughly 25 tokens at 4 chars/token)
|
|
if (onTextCallback) {
|
|
onTextCallback("A".repeat(100));
|
|
}
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result.usageJson).toBeDefined();
|
|
expect(result.usageJson!.inputTokens).toBe(0);
|
|
expect(result.usageJson!.outputTokens).toBe(25); // 100/4 = 25
|
|
expect(result.usageJson!.cachedTokens).toBe(0);
|
|
});
|
|
|
|
it("accumulates usage on agent record", async () => {
|
|
const store = createStoreWithAgentForExec({
|
|
totalInputTokens: 100,
|
|
totalOutputTokens: 200,
|
|
});
|
|
const mockSession = createMockAgentSession();
|
|
let onTextCallback: ((delta: string) => void) | undefined;
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
onTextCallback = opts.onText;
|
|
return { session: mockSession as any };
|
|
});
|
|
|
|
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
|
if (onTextCallback) {
|
|
onTextCallback("A".repeat(100));
|
|
}
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
// Should update cumulative tokens: 200 + 25 = 225
|
|
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", {
|
|
totalInputTokens: 100,
|
|
totalOutputTokens: 225,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("cleanup", () => {
|
|
it("disposes session and untracks agent even on error", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
mockSession.prompt = vi.fn().mockRejectedValue(new Error("Crash"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
// Session disposed
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
// Agent untracked
|
|
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
|
|
});
|
|
|
|
it("disposes session and untracks agent on success", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({
|
|
session: mockSession as any,
|
|
});
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
|
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
expect(monitor.getTrackedAgents()).not.toContain("agent-001");
|
|
});
|
|
});
|
|
|
|
describe("Budget Governance", () => {
|
|
it("skips heartbeat when agent is over budget (timer)", async () => {
|
|
const budgetStatus = createBudgetStatus({
|
|
currentUsage: 10000,
|
|
budgetLimit: 10000,
|
|
usagePercent: 100,
|
|
thresholdPercent: 80,
|
|
isOverBudget: true,
|
|
isOverThreshold: true,
|
|
});
|
|
const store = createStoreWithAgentForExec();
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(budgetStatus);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toMatchObject({ reason: "budget_exhausted", budgetStatus });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
|
});
|
|
|
|
it("skips heartbeat when agent is over budget (on_demand)", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
|
|
);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result.resultJson).toMatchObject({ reason: "budget_exhausted" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips heartbeat when agent is over budget (assignment)", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
createBudgetStatus({ isOverBudget: true, isOverThreshold: true, usagePercent: 100 })
|
|
);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
|
|
|
|
expect(result.resultJson).toMatchObject({ reason: "budget_exhausted" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips timer heartbeat when agent is over threshold but not over budget", async () => {
|
|
const budgetStatus = createBudgetStatus({
|
|
currentUsage: 850,
|
|
budgetLimit: 1000,
|
|
usagePercent: 85,
|
|
thresholdPercent: 80,
|
|
isOverBudget: false,
|
|
isOverThreshold: true,
|
|
});
|
|
const store = createStoreWithAgentForExec();
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(budgetStatus);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.resultJson).toMatchObject({ reason: "budget_threshold_exceeded", budgetStatus });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows on_demand heartbeat when agent is over threshold", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
createBudgetStatus({ isOverThreshold: true, usagePercent: 85, budgetLimit: 1000, thresholdPercent: 80 })
|
|
);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("allows assignment heartbeat when agent is over threshold", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
createBudgetStatus({ isOverThreshold: true, usagePercent: 85, budgetLimit: 1000, thresholdPercent: 80 })
|
|
);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("proceeds normally when agent is below threshold", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
createBudgetStatus({ isOverBudget: false, isOverThreshold: false, usagePercent: 30, budgetLimit: 1000, thresholdPercent: 80 })
|
|
);
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("proceeds normally when getBudgetStatus throws", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
(store.getBudgetStatus as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("budget unavailable"));
|
|
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("Pause Governance", () => {
|
|
it("skips heartbeat on global pause for timer source", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const pauseAwareTaskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({ globalPause: true, enginePaused: false }),
|
|
});
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: pauseAwareTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toMatchObject({ reason: "global_pause", source: "timer" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips heartbeat on global pause for assignment source", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const pauseAwareTaskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({ globalPause: true, enginePaused: false }),
|
|
});
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: pauseAwareTaskStore, rootDir: "/tmp" });
|
|
|
|
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(result.resultJson).toMatchObject({ reason: "global_pause", source: "assignment" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips timer heartbeat on engine pause but allows assignment", async () => {
|
|
const timerStore = createStoreWithAgentForExec();
|
|
const pauseAwareTaskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: true }),
|
|
});
|
|
const timerMonitor = new HeartbeatMonitor({ store: timerStore, taskStore: pauseAwareTaskStore, rootDir: "/tmp" });
|
|
|
|
const timerResult = await timerMonitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
|
|
expect(timerResult.status).toBe("completed");
|
|
expect(timerResult.resultJson).toMatchObject({ reason: "engine_paused", source: "timer" });
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
|
|
const assignmentStore = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
const assignmentMonitor = new HeartbeatMonitor({
|
|
store: assignmentStore,
|
|
taskStore: pauseAwareTaskStore,
|
|
rootDir: "/tmp",
|
|
});
|
|
|
|
const assignmentResult = await assignmentMonitor.executeHeartbeat({
|
|
agentId: "agent-001",
|
|
source: "assignment",
|
|
});
|
|
|
|
expect(assignmentResult.status).toBe("completed");
|
|
expect((assignmentResult.resultJson as Record<string, unknown>)?.reason).not.toBe("engine_paused");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("proceeds when pause flags are false", async () => {
|
|
const store = createStoreWithAgentForExec();
|
|
const mockSession = createMockAgentSession();
|
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
|
const pauseAwareTaskStore = createMockTaskStore({
|
|
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
|
});
|
|
const monitor = new HeartbeatMonitor({ store, taskStore: pauseAwareTaskStore, rootDir: "/tmp" });
|
|
|
|
const timerResult = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
|
const onDemandResult = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
|
const assignmentResult = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
|
|
|
|
expect(timerResult.status).toBe("completed");
|
|
expect(onDemandResult.status).toBe("completed");
|
|
expect(assignmentResult.status).toBe("completed");
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Task Creation Tracking Tests ──────────────────────────────────────
|
|
|