feat(FN-1065): add immediate message response mode for heartbeats

- Add MessageResponseMode and messageResponseMode runtime config to core agent heartbeat types and exports
- Extend MessageStore with an onMessageToAgent hook plus runtime hook updates for agent-directed messages
- Wire HeartbeatMonitor to wake agents on incoming messages when messageResponseMode is immediate and state is eligible
- Update AgentDetailView with a Message Response Mode setting, validation, and runtimeConfig persistence
- Expand core and engine tests to cover hook behavior, wake-on-message triggering, and start/stop hook lifecycle
This commit is contained in:
gsxdsm
2026-04-07 18:43:16 -07:00
parent 06fe36a64c
commit 981c5d951c
7 changed files with 365 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -95,6 +95,86 @@ describe("MessageStore", () => {
});
});
describe("message-to-agent hook", () => {
it("does not call the hook for non-agent recipients", async () => {
const hook = vi.fn();
const hookedStore = new MessageStore({ rootDir: tempDir, onMessageToAgent: hook });
await hookedStore.init();
await hookedStore.sendMessage({
fromId: "agent-1",
fromType: "agent",
toId: "user-1",
toType: "user",
content: "Hello user",
type: "agent-to-user",
});
expect(hook).not.toHaveBeenCalled();
});
it("calls the hook when a message is sent to an agent", async () => {
const hook = vi.fn();
const hookedStore = new MessageStore({ rootDir: tempDir, onMessageToAgent: hook });
await hookedStore.init();
const message = await hookedStore.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Hello agent",
type: "user-to-agent",
});
expect(hook).toHaveBeenCalledTimes(1);
expect(hook).toHaveBeenCalledWith(message);
});
it("does nothing when no hook is configured", async () => {
await expect(
store.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "No hook configured",
type: "user-to-agent",
}),
).resolves.toMatchObject({ toId: "agent-1", toType: "agent" });
});
it("setMessageToAgentHook updates the hook used for subsequent messages", async () => {
const firstHook = vi.fn();
const secondHook = vi.fn();
const hookedStore = new MessageStore({ rootDir: tempDir, onMessageToAgent: firstHook });
await hookedStore.init();
await hookedStore.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "First",
type: "user-to-agent",
});
hookedStore.setMessageToAgentHook(secondHook);
await hookedStore.sendMessage({
fromId: "user-1",
fromType: "user",
toId: "agent-1",
toType: "agent",
content: "Second",
type: "user-to-agent",
});
expect(firstHook).toHaveBeenCalledTimes(1);
expect(secondHook).toHaveBeenCalledTimes(1);
});
});
describe("getInbox()", () => {
it("returns inbox messages for a participant", async () => {
await store.sendMessage({

View File

@@ -39,6 +39,8 @@ export interface MessageStoreEvents {
export interface MessageStoreOptions {
/** Root directory for kb data (default: .fusion) */
rootDir?: string;
/** Optional hook invoked when a message is addressed to an agent */
onMessageToAgent?: (message: Message) => void;
}
/** Index structure for mailbox lookups */
@@ -55,12 +57,14 @@ export class MessageStore extends EventEmitter {
private rootDir: string;
private messagesDir: string;
private indexPath: string;
private onMessageToAgent?: (message: Message) => void;
constructor(options: MessageStoreOptions = {}) {
super();
this.rootDir = options.rootDir ?? ".fusion";
this.messagesDir = join(this.rootDir, "messages");
this.indexPath = join(this.messagesDir, "index.json");
this.onMessageToAgent = options.onMessageToAgent;
}
/**
@@ -109,6 +113,10 @@ export class MessageStore extends EventEmitter {
this.emit("message:sent", message);
this.emit("message:received", message);
if (message.toType === "agent" && this.onMessageToAgent) {
this.onMessageToAgent(message);
}
return message;
}
@@ -291,6 +299,13 @@ export class MessageStore extends EventEmitter {
};
}
/**
* Set or update the hook used when messages are sent to agents.
*/
setMessageToAgentHook(hook: (message: Message) => void): void {
this.onMessageToAgent = hook;
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1506,6 +1506,8 @@ export interface Agent {
instructionsText?: string;
}
export type MessageResponseMode = "immediate" | "on-heartbeat";
/** Per-agent heartbeat configuration, stored in agent.runtimeConfig */
export interface AgentHeartbeatConfig {
/** Whether heartbeat triggers are enabled for this agent (default: true) */
@@ -1516,6 +1518,12 @@ export interface AgentHeartbeatConfig {
heartbeatTimeoutMs?: number;
/** Max concurrent heartbeat runs per agent (default: 1). Min: 1 */
maxConcurrentRuns?: number;
/**
* How this agent responds to incoming messages.
* "immediate" triggers a heartbeat run when a message arrives.
* "on-heartbeat" defers message handling to the next scheduled heartbeat (default).
*/
messageResponseMode?: MessageResponseMode;
}
/** Extended agent information including heartbeat history */