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:
5
.changeset/fn-3620-hermes-mailbox-chat.md
Normal file
5
.changeset/fn-3620-hermes-mailbox-chat.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
fn message inbox
|
||||
fn message outbox
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
min-height: calc(var(--space-md) * 3);
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -269,6 +269,11 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-thread-header-render-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.chat-thread-header-render-toggle--plain {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ const { mockSummarizeTitle } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
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
|
||||
@@ -63,8 +71,8 @@ const mockAgentStore = {
|
||||
listAgents: vi.fn(),
|
||||
};
|
||||
|
||||
function createChatManager(pluginRunner?: Record<string, unknown>): ChatManager {
|
||||
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, pluginRunner as any);
|
||||
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, undefined, messageStore as any);
|
||||
}
|
||||
|
||||
function createChatManagerWithSettings(settings: {
|
||||
@@ -652,7 +660,13 @@ describe("ChatManager.sendMessage", () => {
|
||||
getRuntimeById: 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");
|
||||
|
||||
@@ -661,6 +675,99 @@ describe("ChatManager.sendMessage", () => {
|
||||
runtimeHint: "openclaw",
|
||||
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 () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ChatStore,
|
||||
ChatSession,
|
||||
ChatSessionCreateInput,
|
||||
MessageStore,
|
||||
Settings,
|
||||
} from "@fusion/core";
|
||||
import { summarizeTitle } from "@fusion/core";
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
promptWithFallback as enginePromptWithFallback,
|
||||
extractRuntimeHint,
|
||||
extractRuntimeModel,
|
||||
createSendMessageTool,
|
||||
createReadMessagesTool,
|
||||
} from "@fusion/engine";
|
||||
import * as engineModule from "@fusion/engine";
|
||||
|
||||
@@ -500,6 +503,7 @@ export class ChatManager {
|
||||
createRuntimeContext?(pluginId: string): Promise<unknown>;
|
||||
},
|
||||
private getSettings?: () => Promise<Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined> | Pick<Settings, "fallbackProvider" | "fallbackModelId" | "defaultProvider" | "defaultModelId"> | undefined,
|
||||
private messageStore?: MessageStore,
|
||||
) {}
|
||||
|
||||
private async getChatModelSettings(): Promise<{
|
||||
@@ -946,10 +950,18 @@ export class ChatManager {
|
||||
|| usesConfiguredDefaultModel
|
||||
);
|
||||
|
||||
const messagingTools = agent?.id && this.messageStore
|
||||
? [
|
||||
createSendMessageTool(this.messageStore, agent.id),
|
||||
createReadMessagesTool(this.messageStore, agent.id),
|
||||
]
|
||||
: undefined;
|
||||
|
||||
const sessionOptions = {
|
||||
cwd: this.rootDir,
|
||||
systemPrompt,
|
||||
tools: "coding" as const,
|
||||
...(messagingTools ? { customTools: messagingTools } : {}),
|
||||
sessionManager,
|
||||
...(effectiveModelProvider && effectiveModelId
|
||||
? {
|
||||
|
||||
@@ -942,6 +942,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
chatAgentStore,
|
||||
options?.pluginRunner,
|
||||
() => store.getSettings(),
|
||||
options?.engine?.getMessageStore(),
|
||||
);
|
||||
|
||||
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {
|
||||
|
||||
@@ -4,6 +4,8 @@ export {
|
||||
createTaskDocumentReadTool,
|
||||
createTaskDocumentWriteTool,
|
||||
createTaskLogTool,
|
||||
createSendMessageTool,
|
||||
createReadMessagesTool,
|
||||
taskCreateParams,
|
||||
taskDocumentReadParams,
|
||||
taskDocumentWriteParams,
|
||||
|
||||
Reference in New Issue
Block a user