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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user