feat(FN-2232): check mailbox on every heartbeat run

- Fetch unread inbox messages whenever messageStore is available, not only wake-on-message triggers
- Include pending messages in execution prompts for task and no-task heartbeat runs across timer, assignment, and on-demand sources
- Extend heartbeat messaging tests to cover run-wide mailbox prefetch and read-marking behavior
- Document heartbeat mailbox checking semantics and messageResponseMode behavior in agents docs
- Replace Header mailbox badge class with existing header-badge style token
This commit is contained in:
Fusion
2026-04-22 02:22:52 -07:00
committed by gsxdsm
parent e2b69409be
commit 3afa378594
5 changed files with 273 additions and 34 deletions

View File

@@ -1671,6 +1671,58 @@ describe("HeartbeatMonitor", () => {
expect(toolNames).not.toContain("send_message");
expect(toolNames).not.toContain("read_messages");
});
it("identity agent without task fetches messages and includes them in prompt for timer trigger", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const messages = [
createMessage({
id: "msg-notask-1",
fromId: "user-1",
content: "Please check the task board",
}),
createMessage({
id: "msg-notask-2",
fromId: "agent-5",
content: "Delegating FN-100 to you",
}),
];
const messageStore = {
setMessageToAgentHook: vi.fn(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
const monitor = new HeartbeatMonitor({
store,
messageStore,
taskStore: mockTaskStore,
rootDir: "/tmp",
});
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "timer",
triggerDetail: "scheduled",
});
expect(result.status).toBe("completed");
// Messages should be fetched for no-task runs too
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
// Messages should be marked as read after successful execution
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
// Verify execution prompt included the messages
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("Please check the task board");
expect(executionPrompt).toContain("Delegating FN-100 to you");
});
});
describe("blocked-task dedup", () => {
@@ -2087,14 +2139,22 @@ describe("HeartbeatMonitor", () => {
expect(messageStore.markAllAsRead).not.toHaveBeenCalled();
});
it("does not fetch messages when not wake-on-message trigger", async () => {
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(),
getInbox: vi.fn().mockReturnValue(messages),
markAllAsRead: vi.fn(),
} as unknown as MessageStore;
@@ -2105,15 +2165,159 @@ describe("HeartbeatMonitor", () => {
rootDir: "/tmp",
});
// Use a regular trigger (not wake-on-message)
await monitor.executeHeartbeat({
// Use a timer trigger (not wake-on-message)
const result = await monitor.executeHeartbeat({
agentId: "agent-001",
source: "timer",
triggerDetail: "scheduled",
});
expect(messageStore.getInbox).not.toHaveBeenCalled();
expect(messageStore.markAllAsRead).not.toHaveBeenCalled();
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 agent-to-agent message flow", () => {

View File

@@ -1171,12 +1171,12 @@ export class HeartbeatMonitor {
if (isNoTaskRun) {
// No-task heartbeat: agent has identity but no assigned task
// Fetch unread messages when woken by message trigger
if (triggerDetail === "wake-on-message" && this.messageStore) {
// Fetch unread messages when messageStore is available (for all trigger types)
if (this.messageStore) {
try {
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
} catch (inboxErr) {
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId} during wake-on-message: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId}: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
}
}
@@ -1226,12 +1226,12 @@ export class HeartbeatMonitor {
// Task-scoped heartbeat: agent has an assigned task
const taskTitle = taskDetail!.title ?? taskDetail!.description.slice(0, 100);
// Fetch unread messages when woken by message trigger
if (triggerDetail === "wake-on-message" && this.messageStore) {
// Fetch unread messages when messageStore is available (for all trigger types)
if (this.messageStore) {
try {
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
} catch (inboxErr) {
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId} during wake-on-message: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId}: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
}
}