FN-7854: fix desktop chat losing messaging tools from stale MessageStore wiring
Project-scoped chat managers can be cached before the project engine boots, so fn_send_message/fn_read_messages were silently dropped for lazily-booted (desktop) sessions while browser sessions kept them; the fix refreshes the cached manager's MessageStore post-construction and surfaces a diagnostic + chat-stream warning when the reduced tool schema condition occurs instead of failing silently. Key changes: - ChatManager gains setMessageStore() to refresh a cached manager's MessageStore post-construction, mirroring the existing setPluginRunner() refresh seam - getOrCreateScopedChatManager()/resolveScopedChatManager() now accept and wire an optional MessageStore, upgrading already-cached managers instead of leaving them stale - register-chat-routes.ts now passes engine.getMessageStore() through to the scoped chat manager resolver - ChatManager emits a new 'warning' chat-stream event (code: tool-schema-reduced) plus a diagnostics.warn() call when a bound agent has no MessageStore, so reduced tool schema is agent-visible instead of a silent per-call failure - Added regression tests covering MessageStore wiring/refresh in chat-project-services and chat-manager, plus a patch changeset documenting the fix Files changed: .changeset/fn-7854-chat-tool-schema-parity.md | 7 ++ .../dashboard/src/__tests__/chat-manager.test.ts | 124 ++++++++++++++++++++- .../src/__tests__/chat-project-services.test.ts | 67 +++++++++++ packages/dashboard/src/chat-project-services.ts | 10 +- packages/dashboard/src/chat.ts | 39 +++++++ .../dashboard/src/routes/register-chat-routes.ts | 2 +- 6 files changed, 245 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7854 Fusion-Task-Lineage: 1d1ee3e7-608b-4b7d-be45-138b38b27f17 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7854-chat-tool-schema-parity.md
Normal file
7
.changeset/fn-7854-chat-tool-schema-parity.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Agent chat exposes the same tools on desktop and browser; messaging tools no longer silently drop.
|
||||
category: fix
|
||||
dev: Project-scoped chat (getOrCreateScopedChatManager/resolveScopedChatManager) now wires and refreshes the engine MessageStore, mirroring setPluginRunner, so fn_send_message/fn_read_messages survive lazy engine boot; a reduced-tool-schema condition now emits a diagnostic/agent-visible signal instead of failing silently per call (FN-7854).
|
||||
@@ -1592,6 +1592,126 @@ describe("ChatManager.sendMessage", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("exposes mailbox tools when an agent-bound chat has a MessageStore", 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);
|
||||
|
||||
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 toolNames = (createResolvedSession.mock.calls[0]?.[0]?.customTools ?? []).map((tool: { name: string }) => tool.name);
|
||||
expect(toolNames).toContain("fn_send_message");
|
||||
expect(toolNames).toContain("fn_read_messages");
|
||||
});
|
||||
|
||||
it("signals reduced tool schema when an agent-bound chat has no MessageStore", async () => {
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
projectId: "project-a",
|
||||
});
|
||||
const diagnosticsWarn = vi.fn();
|
||||
__setChatDiagnostics({
|
||||
...__getChatDiagnostics(),
|
||||
warn: diagnosticsWarn,
|
||||
});
|
||||
const events: Array<{ type: string; data: any }> = [];
|
||||
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
|
||||
events.push(event as any);
|
||||
});
|
||||
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 chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
unsubscribe();
|
||||
|
||||
const toolNames = (createResolvedSession.mock.calls[0]?.[0]?.customTools ?? []).map((tool: { name: string }) => tool.name);
|
||||
expect(toolNames).not.toContain("fn_send_message");
|
||||
expect(toolNames).not.toContain("fn_read_messages");
|
||||
expect(diagnosticsWarn).toHaveBeenCalledWith("Project chat tool schema reduced", expect.objectContaining({
|
||||
sessionId: "chat-001",
|
||||
agentId: "agent-001",
|
||||
projectId: "project-a",
|
||||
reason: "message-store-unavailable",
|
||||
}));
|
||||
expect(events).toContainEqual({
|
||||
type: "warning",
|
||||
data: expect.objectContaining({
|
||||
code: "tool-schema-reduced",
|
||||
toolSchemaReduced: true,
|
||||
reason: "message-store-unavailable",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("adds mailbox tools after setMessageStore upgrades a cached manager", async () => {
|
||||
const firstCreateResolvedSession = vi.fn(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Before upgrade" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
__setCreateResolvedAgentSession(firstCreateResolvedSession as any);
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Before engine boot");
|
||||
const firstToolNames = (firstCreateResolvedSession.mock.calls[0]?.[0]?.customTools ?? []).map((tool: { name: string }) => tool.name);
|
||||
expect(firstToolNames).not.toContain("fn_send_message");
|
||||
|
||||
const messageStore = {
|
||||
sendMessage: vi.fn().mockReturnValue({ id: "msg-123" }),
|
||||
getInbox: vi.fn().mockReturnValue([]),
|
||||
markAsRead: vi.fn(),
|
||||
markAllAsRead: vi.fn(),
|
||||
};
|
||||
chatManager.setMessageStore(messageStore as any);
|
||||
|
||||
const secondCreateResolvedSession = vi.fn(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "After upgrade" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
__setCreateResolvedAgentSession(secondCreateResolvedSession as any);
|
||||
|
||||
await chatManager.sendMessage("chat-001", "After engine boot");
|
||||
|
||||
const secondToolNames = (secondCreateResolvedSession.mock.calls[0]?.[0]?.customTools ?? []).map((tool: { name: string }) => tool.name);
|
||||
expect(secondToolNames).toContain("fn_send_message");
|
||||
expect(secondToolNames).toContain("fn_read_messages");
|
||||
});
|
||||
|
||||
it("injects ask-question but not mailbox tools for non-agent chat sessions", async () => {
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-001",
|
||||
@@ -2581,7 +2701,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
expect(promptArgument).toContain("## Attachments");
|
||||
expect(promptArgument).toContain("session attachment bytes");
|
||||
expect(promptOptions).toEqual({
|
||||
images: [{ type: "image", data: Buffer.from([9, 8, 7]).toString("base64"), mimeType: "image/png" }],
|
||||
images: [expect.objectContaining({ type: "image", data: Buffer.from([9, 8, 7]).toString("base64"), mimeType: "image/png" })],
|
||||
});
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
@@ -3414,7 +3534,7 @@ describe("ChatManager generation isolation", () => {
|
||||
expect(promptArgument).toContain("## Attachments");
|
||||
expect(promptArgument).toContain("room attachment bytes");
|
||||
expect(promptOptions).toEqual({
|
||||
images: [{ type: "image", data: Buffer.from([5, 4, 3]).toString("base64"), mimeType: "image/webp" }],
|
||||
images: [expect.objectContaining({ type: "image", data: Buffer.from([5, 4, 3]).toString("base64"), mimeType: "image/webp" })],
|
||||
});
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetScopedChatManagerCache,
|
||||
getOrCreateScopedChatManager,
|
||||
} from "../chat-project-services.js";
|
||||
|
||||
function createStore(fusionDir = "/tmp/fusion-project") {
|
||||
return {
|
||||
getFusionDir: vi.fn(() => fusionDir),
|
||||
getRootDir: vi.fn(() => "/tmp/project"),
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
getDatabase: vi.fn(() => ({})),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createChatStore() {
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
describe("project-scoped ChatManager cache", () => {
|
||||
beforeEach(() => {
|
||||
__resetScopedChatManagerCache();
|
||||
});
|
||||
|
||||
it("passes the engine MessageStore into a newly constructed scoped manager", () => {
|
||||
const store = createStore();
|
||||
const chatStore = createChatStore();
|
||||
const pluginRunner = { getRuntimeById: vi.fn() };
|
||||
const messageStore = { sendMessage: vi.fn(), getInbox: vi.fn() };
|
||||
|
||||
const manager = getOrCreateScopedChatManager(store, chatStore, pluginRunner as any, true, messageStore as any);
|
||||
|
||||
expect((manager as any).messageStore).toBe(messageStore);
|
||||
});
|
||||
|
||||
it("upgrades a cached manager when the engine boots after first resolution", () => {
|
||||
const store = createStore();
|
||||
const chatStore = createChatStore();
|
||||
const initialPluginRunner = { getRuntimeById: vi.fn(() => undefined) };
|
||||
const enginePluginRunner = { getRuntimeById: vi.fn(() => ({ id: "runtime" })) };
|
||||
const messageStore = { sendMessage: vi.fn(), getInbox: vi.fn() };
|
||||
|
||||
const preBootManager = getOrCreateScopedChatManager(store, chatStore, initialPluginRunner as any, false, undefined);
|
||||
expect((preBootManager as any).messageStore).toBeUndefined();
|
||||
|
||||
const upgradedManager = getOrCreateScopedChatManager(store, chatStore, enginePluginRunner as any, true, messageStore as any);
|
||||
|
||||
expect(upgradedManager).toBe(preBootManager);
|
||||
expect((upgradedManager as any).pluginRunner).toBe(enginePluginRunner);
|
||||
expect((upgradedManager as any).messageStore).toBe(messageStore);
|
||||
});
|
||||
|
||||
it("preserves plugin-runner refresh semantics alongside MessageStore refresh", () => {
|
||||
const store = createStore();
|
||||
const chatStore = createChatStore();
|
||||
const fallbackPluginRunner = { getRuntimeById: vi.fn(() => undefined) };
|
||||
const enginePluginRunner = { getRuntimeById: vi.fn(() => ({ id: "runtime" })) };
|
||||
const messageStore = { sendMessage: vi.fn(), getInbox: vi.fn() };
|
||||
|
||||
const manager = getOrCreateScopedChatManager(store, chatStore, fallbackPluginRunner as any, false, undefined);
|
||||
const cached = getOrCreateScopedChatManager(store, chatStore, enginePluginRunner as any, true, messageStore as any);
|
||||
|
||||
expect(cached).toBe(manager);
|
||||
expect((cached as any).pluginRunner).toBe(enginePluginRunner);
|
||||
expect((cached as any).messageStore).toBe(messageStore);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,7 @@ export function getOrCreateScopedChatManager(
|
||||
chatStore: ChatStore,
|
||||
pluginRunner?: ConstructorParameters<typeof ChatManager>[3],
|
||||
refreshPluginRunner = false,
|
||||
messageStore?: MessageStore,
|
||||
): ChatManager {
|
||||
const key = store.getFusionDir();
|
||||
const cached = scopedChatManagerCache.get(key);
|
||||
@@ -98,16 +99,23 @@ export function getOrCreateScopedChatManager(
|
||||
if (refreshPluginRunner && pluginRunner) {
|
||||
cached.setPluginRunner(pluginRunner);
|
||||
}
|
||||
if (messageStore) {
|
||||
cached.setMessageStore(messageStore);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
/*
|
||||
* FNXC:ProjectChatRuntime 2026-07-12-11:00:
|
||||
* Project/agent chat must expose the same tool schema over desktop and browser transports. The scoped manager is cached by fusion dir, so lazy engine boot must upgrade the cached MessageStore instead of leaving fn_send_message/fn_read_messages stale-missing after the first pre-engine resolution.
|
||||
*/
|
||||
const manager = new ChatManager(
|
||||
chatStore,
|
||||
store.getRootDir(),
|
||||
agentStore,
|
||||
pluginRunner,
|
||||
() => store.getSettings(),
|
||||
undefined,
|
||||
messageStore,
|
||||
store,
|
||||
);
|
||||
scopedChatManagerCache.set(key, manager);
|
||||
|
||||
@@ -690,6 +690,17 @@ export type ChatStreamEvent =
|
||||
| { type: "tool_start"; data: { toolName: string; args?: Record<string, unknown> } }
|
||||
| { type: "tool_end"; data: { toolName: string; isError: boolean; result?: unknown } }
|
||||
| { type: "fallback"; data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" } }
|
||||
| {
|
||||
type: "warning";
|
||||
data: {
|
||||
code: "tool-schema-reduced";
|
||||
toolSchemaReduced: true;
|
||||
reason: "message-store-unavailable";
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
projectId: string | null;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "done";
|
||||
data: {
|
||||
@@ -1099,6 +1110,14 @@ export class ChatManager {
|
||||
this.pluginRunner = pluginRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ProjectChatRuntime 2026-07-12-11:00:
|
||||
* Project-scoped chat managers can be constructed before the project engine boots, so the engine MessageStore that provides fn_send_message/fn_read_messages must be refreshable post-construction like the plugin runner. Without this FN-7854 refresh seam, a cached desktop manager keeps messaging tools permanently stripped for that session.
|
||||
*/
|
||||
setMessageStore(messageStore: MessageStore | undefined): void {
|
||||
this.messageStore = messageStore;
|
||||
}
|
||||
|
||||
private getPluginRunnerForSkillSelection(): Parameters<typeof buildSessionSkillContextSync>[3] {
|
||||
return this.pluginRunner?.getPluginSkills
|
||||
? (this.pluginRunner as unknown as Parameters<typeof buildSessionSkillContextSync>[3])
|
||||
@@ -2247,6 +2266,26 @@ export class ChatManager {
|
||||
*/
|
||||
const effectiveThinkingLevel = resolveExecutorThinkingLevel(session.thinkingLevel ?? undefined, chatModelSettings);
|
||||
|
||||
if (agent?.id && !this.messageStore) {
|
||||
const warning = {
|
||||
code: "tool-schema-reduced" as const,
|
||||
toolSchemaReduced: true as const,
|
||||
reason: "message-store-unavailable" as const,
|
||||
sessionId,
|
||||
agentId: agent.id,
|
||||
projectId: session.projectId ?? null,
|
||||
};
|
||||
/*
|
||||
* FNXC:ChatAgentTools 2026-07-12-11:00:
|
||||
* A bound agent with reduced tools must receive an observable signal instead of later discovering missing coordination tools one failed call at a time. FN-7854 keeps the signal lightweight by using diagnostics plus the existing chat stream channel when MessageStore-backed messaging tools cannot be assembled.
|
||||
*/
|
||||
diagnostics.warn("Project chat tool schema reduced", warning);
|
||||
chatStreamManager.broadcast(sessionId, {
|
||||
type: "warning",
|
||||
data: warning,
|
||||
}, broadcastOptions);
|
||||
}
|
||||
|
||||
const messagingTools = agent?.id && this.messageStore
|
||||
? [
|
||||
createSendMessageTool(this.messageStore, agent.id),
|
||||
|
||||
@@ -132,7 +132,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
const engine = options?.engineManager?.getEngine(projectId);
|
||||
const projectPluginRunner = engine?.getPluginRunner?.();
|
||||
const pluginRunner = projectPluginRunner ?? options?.pluginRunner;
|
||||
return getOrCreateScopedChatManager(scopedStore, chatStore, pluginRunner, Boolean(projectPluginRunner));
|
||||
return getOrCreateScopedChatManager(scopedStore, chatStore, pluginRunner, Boolean(projectPluginRunner), engine?.getMessageStore());
|
||||
}
|
||||
const THINKING_LEVEL_SET = new Set<string>(THINKING_LEVELS);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user