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.
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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") => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export {
|
|||||||
createTaskDocumentReadTool,
|
createTaskDocumentReadTool,
|
||||||
createTaskDocumentWriteTool,
|
createTaskDocumentWriteTool,
|
||||||
createTaskLogTool,
|
createTaskLogTool,
|
||||||
|
createSendMessageTool,
|
||||||
|
createReadMessagesTool,
|
||||||
taskCreateParams,
|
taskCreateParams,
|
||||||
taskDocumentReadParams,
|
taskDocumentReadParams,
|
||||||
taskDocumentWriteParams,
|
taskDocumentWriteParams,
|
||||||
|
|||||||
Reference in New Issue
Block a user