fix(chat): use per-project ChatManager for multi-project message routing

In multi-project mode the global chatManager was backed by ~/.fusion/fusion.db.
Secondary project chat sessions live in per-project DBs, so
chatManager.sendMessage() failed with 'Chat session not found' for any
project that is not the daemon's CWD.

Fix: add getOrCreateScopedChatManager to chat-project-services.ts (cached by
fusionDir, same pattern as the existing chatStore cache). POST /messages,
GET /stream, POST /cancel, and GET /sessions isGenerating enrichment all
resolve the per-project ChatManager when projectId is provided.

Fallback to global chatManager when no projectId (preserves single-project mode).

Tests: added multi-project chat routing describe block in chat-routes.test.ts.
This commit is contained in:
Josemi Liebana
2026-05-26 16:16:38 +02:00
parent 435963d231
commit 0ce1b62809
3 changed files with 172 additions and 13 deletions

View File

@@ -1415,3 +1415,117 @@ describe("Chat API Routes", () => {
// const chatStore = options?.chatStore ?? new ChatStore(...)
// This means the route won't fail when chatStore is undefined in tests.
});
// ── multi-project chat routing ──────────────────────────────────────────────
describe("multi-project chat routing", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let mockChatStore: typeof mockChatStoreInstance;
let mockChatManager: ReturnType<typeof createMockChatManager>;
const secondarySession = {
id: "secondary-session-1",
agentId: "agent-001",
title: "Secondary Chat",
status: "active",
projectId: "secondary-proj-id",
modelProvider: null,
modelId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
beforeEach(async () => {
vi.clearAllMocks();
// Reset per-project caches to ensure no cross-test pollution
const { __resetScopedChatManagerCache, __resetScopedChatStoreCache } = await import("../chat-project-services.js");
__resetScopedChatManagerCache();
__resetScopedChatStoreCache();
mockInit.mockResolvedValue(undefined);
mockGetSession.mockReset();
mockListSessions.mockReset();
mockGetLastMessageForSessions.mockReset();
mockSendMessage.mockReset();
mockCancelGeneration.mockReset();
mockBeginGeneration.mockReturnValue({ generationId: 1, abortController: new AbortController() });
mockIsGenerating.mockReturnValue(false);
mockGetActiveGenerationId.mockReturnValue(undefined);
mockGetOrCreateProjectStore.mockReset();
// Default list/message mocks
mockListSessions.mockReturnValue([]);
mockGetLastMessageForSessions.mockReturnValue(new Map());
mockCancelGeneration.mockReturnValue(false);
store = new MockStore();
mockChatStore = mockChatStoreInstance;
mockChatManager = createMockChatManager();
// Secondary project store resolves to same MockStore for simplicity
mockGetOrCreateProjectStore.mockResolvedValue(store);
const { createServer } = await import("../server.js");
app = createServer(store as any, {
chatStore: mockChatStore as any,
chatManager: mockChatManager as any,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("POST /cancel uses scoped ChatManager when projectId is provided", async () => {
mockCancelGeneration.mockReturnValue(false);
const response = await request(
app,
"POST",
`/api/chat/sessions/${secondarySession.id}/cancel?projectId=${secondarySession.projectId}`,
);
expect(response.status).toBe(200);
expect((response.body as any).success).toBe(false);
// Scoped path: getOrCreateProjectStore is called with the secondary projectId
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId);
// cancelGeneration was called on the scoped manager
expect(mockCancelGeneration).toHaveBeenCalledWith(secondarySession.id);
});
it("POST /cancel falls back to global ChatManager when no projectId", async () => {
mockCancelGeneration.mockReturnValue(false);
const response = await request(
app,
"POST",
`/api/chat/sessions/${secondarySession.id}/cancel`,
);
expect(response.status).toBe(200);
expect((response.body as any).success).toBe(false);
// Global path: getOrCreateProjectStore is NOT called
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
// cancelGeneration was called on the global manager
expect(mockCancelGeneration).toHaveBeenCalledWith(secondarySession.id);
});
it("GET /sessions uses scoped ChatManager for isGenerating when projectId is provided", async () => {
mockListSessions.mockReturnValue([secondarySession]);
mockGetLastMessageForSessions.mockReturnValue(new Map());
const response = await request(
app,
"GET",
`/api/chat/sessions?projectId=${secondarySession.projectId}`,
);
expect(response.status).toBe(200);
expect((response.body as any).sessions).toHaveLength(1);
// Scoped path: getOrCreateProjectStore is called for isGenerating resolution
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId);
// isGenerating defaults to false (MockChatManager has no getGeneratingSessionIds)
expect((response.body as any).sessions[0].isGenerating).toBe(false);
});
});

View File

@@ -74,3 +74,29 @@ export async function createProjectScopedChatManager(options: {
export function __resetScopedChatStoreCache(): void {
scopedChatStoreCache.clear();
}
const scopedChatManagerCache = new Map<string, ChatManager>();
export function getOrCreateScopedChatManager(
store: TaskStore,
chatStore: ChatStore,
pluginRunner?: ConstructorParameters<typeof ChatManager>[3],
): ChatManager {
const key = store.getFusionDir();
const cached = scopedChatManagerCache.get(key);
if (cached) return cached;
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
const manager = new ChatManager(
chatStore,
store.getRootDir(),
agentStore,
pluginRunner,
() => store.getSettings(),
);
scopedChatManagerCache.set(key, manager);
return manager;
}
export function __resetScopedChatManagerCache(): void {
scopedChatManagerCache.clear();
}

View File

@@ -2,12 +2,14 @@ import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import type { EnrichedChatSession, ChatAttachment } from "@fusion/core";
import type { EnrichedChatSession, ChatAttachment, TaskStore, ChatStore } from "@fusion/core";
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js";
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
import type { ApiRoutesContext } from "./types.js";
import { getOrCreateScopedChatManager, getOrCreateScopedChatStore } from "../chat-project-services.js";
import { getOrCreateProjectStore } from "../project-store-resolver.js";
interface ChatRouteDeps {
parseLastEventId: (req: import("express").Request) => number | undefined;
@@ -45,6 +47,22 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
});
};
// ── Per-project ChatManager resolution ──────────────────────────────────────
async function resolveScopedChatStore(projectId: string): Promise<{ store: TaskStore; chatStore: ChatStore }> {
const store = await getOrCreateProjectStore(projectId);
const chatStore = getOrCreateScopedChatStore(store);
return { store, chatStore };
}
async function resolveScopedChatManager(projectId: string | undefined) {
if (!projectId) {
if (!options?.chatManager) throw new ApiError(503, "Chat manager not available");
return options.chatManager;
}
const { store, chatStore } = await resolveScopedChatStore(projectId);
return getOrCreateScopedChatManager(store, chatStore, options?.pluginRunner);
}
// ── Chat Routes ────────────────────────────────────────────────────────────
/**
@@ -108,7 +126,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
const lastMessages = chatStore.getLastMessageForSessions(sessionIds);
// Batch-gather generating session IDs to avoid N+1 calls
const generatingIds = options?.chatManager?.getGeneratingSessionIds?.() ?? [];
const resolvedChatManager = projectId
? await resolveScopedChatManager(projectId).catch(() => options?.chatManager)
: options?.chatManager;
const generatingIds = resolvedChatManager?.getGeneratingSessionIds?.() ?? [];
const generatingSet = new Set(generatingIds);
for (const session of sessions) {
@@ -457,10 +478,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
router.get("/chat/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), async (req, res) => {
try {
const chatStore = options?.chatStore;
const chatManager = options?.chatManager;
if (!chatStore || !chatManager) {
throw internalError("Chat store or manager not available");
if (!chatStore) {
throw internalError("Chat store not available");
}
const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
const session = chatStore.getSession(sessionId);
@@ -546,9 +567,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
router.post("/chat/sessions/:id/messages", rateLimit(RATE_LIMITS.sse), async (req, res) => {
try {
const chatStore = options?.chatStore;
const chatManager = options?.chatManager;
if (!chatStore || !chatManager) {
throw internalError("Chat store or manager not available");
if (!chatStore) {
throw internalError("Chat store not available");
}
const { content, modelProvider, modelId, attachments } = req.body as {
@@ -605,6 +625,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
}
}
// Resolve per-project ChatManager (falls back to global when no projectId)
const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined);
// Allocate a generation up front so subscription and sendMessage broadcasts
// share the same id. This filters out stragglers from a prior, just-cancelled
// generation that would otherwise hit this fresh subscriber and falsely look
@@ -689,11 +712,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.post("/chat/sessions/:id/cancel", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const chatManager = options?.chatManager;
if (!chatManager) {
throw new ApiError(503, "Chat manager not available");
}
const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
const success = chatManager.cancelGeneration(sessionId);
res.json({ success });