feat(FN-3620): wire Hermes chat message tools and document mailbox behavior

Merges FN-3620, completing the Hermes chat mailbox integration with wired message tools, locked sender/recipient contract tests, and mailbox behavior documentation, plus a small CSS polish adding `focus-visible` styles and token radius to the render toggle. A changeset for FN-3710 (cluster task ID o

Fusion-Task-Id: FN-3620
This commit is contained in:
Fusion
2026-05-07 16:50:58 -07:00
committed by gsxdsm
parent 3a944236e1
commit f894bdc1d6
7 changed files with 138 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix dashboard agent chat sessions so plugin runtimes (including Hermes) receive Fusion mailbox tools when a message store is available, enabling real `fn_send_message`/`fn_read_messages` usage with correct agent-to-dashboard recipient routing semantics.

View File

@@ -628,6 +628,8 @@ To clear a specific override, click the **Reset** button in the UI. This sends `
Messaging is available in dashboard mailbox UI and CLI. Messaging is available in dashboard mailbox UI and CLI.
Agent-backed dashboard chat sessions (including plugin-runtime agents such as Hermes/OpenClaw/Paperclip) also expose mailbox tools (`fn_send_message`, `fn_read_messages`) when a `MessageStore` is wired for that project. Model-only chats without an attached agent do not expose these tools.
```bash ```bash
fn message inbox fn message inbox
fn message outbox fn message outbox

View File

@@ -256,7 +256,7 @@
min-height: calc(var(--space-md) * 3); min-height: calc(var(--space-md) * 3);
padding: 0; padding: 0;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
color: var(--text-muted); color: var(--text-muted);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
@@ -269,6 +269,11 @@
color: var(--text); color: var(--text);
} }
.chat-thread-header-render-toggle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-thread-header-render-toggle--plain { .chat-thread-header-render-toggle--plain {
color: var(--text); color: var(--text);
} }

View File

@@ -24,6 +24,14 @@ const { mockSummarizeTitle } = vi.hoisted(() => ({
vi.mock("@fusion/core", () => ({ vi.mock("@fusion/core", () => ({
summarizeTitle: mockSummarizeTitle, summarizeTitle: mockSummarizeTitle,
DASHBOARD_USER_ID: "dashboard",
normalizeMessageParticipant: (id: string, type: "user" | "agent" | "system") => {
const normalized = id.trim();
if (type === "user" && ["dashboard", "user:dashboard", "User: user:dashboard"].includes(normalized)) {
return { id: "dashboard", type: "user" as const };
}
return { id: normalized, type };
},
})); }));
// SessionManager is constructed per-chat for CLI session continuity. We don't // SessionManager is constructed per-chat for CLI session continuity. We don't
@@ -63,8 +71,8 @@ const mockAgentStore = {
listAgents: vi.fn(), listAgents: vi.fn(),
}; };
function createChatManager(pluginRunner?: Record<string, unknown>): ChatManager { function createChatManager(pluginRunner?: Record<string, unknown>, messageStore?: Record<string, unknown>): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any); return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any, undefined, messageStore as any);
} }
function createChatManagerWithSettings(settings: { function createChatManagerWithSettings(settings: {
@@ -652,7 +660,13 @@ describe("ChatManager.sendMessage", () => {
getRuntimeById: vi.fn(), getRuntimeById: vi.fn(),
createRuntimeContext: vi.fn(), createRuntimeContext: vi.fn(),
}; };
const chatManager = createChatManager(pluginRunner); const messageStore = {
sendMessage: vi.fn(),
getInbox: vi.fn(),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
};
const chatManager = createChatManager(pluginRunner, messageStore);
await chatManager.sendMessage("chat-001", "Hello"); await chatManager.sendMessage("chat-001", "Hello");
@@ -661,6 +675,99 @@ describe("ChatManager.sendMessage", () => {
runtimeHint: "openclaw", runtimeHint: "openclaw",
pluginRunner, pluginRunner,
})); }));
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
customTools: expect.arrayContaining([
expect.objectContaining({ name: "fn_send_message" }),
expect.objectContaining({ name: "fn_read_messages" }),
]),
}));
});
it("routes Hermes mailbox sends from agent to canonical dashboard user", async () => {
const createResolvedSession = vi.fn(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Runtime response" }],
},
},
}));
__setCreateResolvedAgentSession(createResolvedSession as any);
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be calm and precise.",
memory: "Remember to keep test coverage high.",
instructionsText: "Keep replies focused.",
runtimeConfig: {
runtimeHint: "hermes-runtime",
},
});
const messageStore = {
sendMessage: vi.fn().mockReturnValue({ id: "msg-123" }),
getInbox: vi.fn().mockReturnValue([]),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
};
const chatManager = createChatManager(undefined, messageStore);
await chatManager.sendMessage("chat-001", "Hello");
const customTools = createResolvedSession.mock.calls[0]?.[0]?.customTools ?? [];
const sendTool = customTools.find((tool: { name: string }) => tool.name === "fn_send_message");
expect(sendTool).toBeDefined();
const sendResult = await sendTool.execute("call-1", {
to_id: "User: user:dashboard",
content: "status",
type: "agent-to-user",
}, undefined, undefined, undefined);
expect(sendResult.content[0]?.type === "text" ? sendResult.content[0].text : "").toContain("Message sent to dashboard");
expect(messageStore.sendMessage).toHaveBeenCalledWith(expect.objectContaining({
fromId: "agent-001",
fromType: "agent",
toId: "dashboard",
toType: "user",
type: "agent-to-user",
}));
});
it("does not inject mailbox tools for non-agent chat sessions", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: null,
status: "active",
});
const createResolvedSession = vi.fn(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Runtime response" }],
},
},
}));
__setCreateResolvedAgentSession(createResolvedSession as any);
const messageStore = {
sendMessage: vi.fn(),
getInbox: vi.fn(),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
};
const chatManager = createChatManager(undefined, messageStore);
await chatManager.sendMessage("chat-001", "Hello");
expect(createResolvedSession).toHaveBeenCalledWith(expect.not.objectContaining({
customTools: expect.anything(),
}));
}); });
it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => { it("uses the assigned built-in pi agent model when the chat session has no explicit model override", async () => {

View File

@@ -20,6 +20,7 @@ import type {
ChatStore, ChatStore,
ChatSession, ChatSession,
ChatSessionCreateInput, ChatSessionCreateInput,
MessageStore,
Settings, Settings,
} from "@fusion/core"; } from "@fusion/core";
import { summarizeTitle } from "@fusion/core"; import { summarizeTitle } from "@fusion/core";
@@ -35,6 +36,8 @@ import {
promptWithFallback as enginePromptWithFallback, promptWithFallback as enginePromptWithFallback,
extractRuntimeHint, extractRuntimeHint,
extractRuntimeModel, extractRuntimeModel,
createSendMessageTool,
createReadMessagesTool,
} from "@fusion/engine"; } from "@fusion/engine";
import * as engineModule from "@fusion/engine"; import * as engineModule from "@fusion/engine";
@@ -500,6 +503,7 @@ export class ChatManager {
createRuntimeContext?(pluginId: string): Promise<unknown>; createRuntimeContext?(pluginId: string): Promise<unknown>;
}, },
private getSettings?: () => Promise<Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined> | Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined, private getSettings?: () => Promise<Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined> | Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined,
private messageStore?: MessageStore,
) {} ) {}
private async getChatModelSettings(): Promise<{ private async getChatModelSettings(): Promise<{
@@ -946,10 +950,18 @@ export class ChatManager {
|| usesConfiguredDefaultModel || usesConfiguredDefaultModel
); );
const messagingTools = agent?.id && this.messageStore
? [
createSendMessageTool(this.messageStore, agent.id),
createReadMessagesTool(this.messageStore, agent.id),
]
: undefined;
const sessionOptions = { const sessionOptions = {
cwd: this.rootDir, cwd: this.rootDir,
systemPrompt, systemPrompt,
tools: "coding" as const, tools: "coding" as const,
...(messagingTools ? { customTools: messagingTools } : {}),
sessionManager, sessionManager,
...(effectiveModelProvider && effectiveModelId ...(effectiveModelProvider && effectiveModelId
? { ? {

View File

@@ -942,6 +942,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
chatAgentStore, chatAgentStore,
options?.pluginRunner, options?.pluginRunner,
() => store.getSettings(), () => store.getSettings(),
options?.engine?.getMessageStore(),
); );
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => { const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {

View File

@@ -4,6 +4,8 @@ export {
createTaskDocumentReadTool, createTaskDocumentReadTool,
createTaskDocumentWriteTool, createTaskDocumentWriteTool,
createTaskLogTool, createTaskLogTool,
createSendMessageTool,
createReadMessagesTool,
taskCreateParams, taskCreateParams,
taskDocumentReadParams, taskDocumentReadParams,
taskDocumentWriteParams, taskDocumentWriteParams,