fix(dashboard): use per-project chatStore in chat session API routes

In multi-project mode, all /chat/sessions* handlers used options.chatStore
(the home-dir project's store) regardless of the projectId query param.
Sessions in secondary projects were invisible via the API.

Root cause: registerChatRoutes accessed options.chatStore directly instead
of routing through resolveProjectChatContext (already used correctly in
registerChatRoomRoutes for the rooms API).

Fix: introduce resolveScopedChatStore(projectId) helper that delegates to
resolveProjectChatContext, replacing all ten options.chatStore usages.
Falls back to default store when engineManager is absent (backward compat).

Tests: add two cases to verify engine-scoped chatStore is used when
engineManager is configured for the requested projectId.
This commit is contained in:
Josemi Liebana
2026-05-22 12:11:14 +02:00
parent fbf7e2cb4d
commit 7f01b5341c
3 changed files with 114 additions and 41 deletions

View File

@@ -0,0 +1,21 @@
---
"@runfusion/fusion": patch
---
Fix chat session API endpoints ignoring `projectId` in multi-project mode.
`GET /chat/sessions`, `GET /chat/sessions/:id`, `GET /chat/sessions/:id/messages`
and related mutation endpoints all used `options.chatStore` (the home-directory
project's store) regardless of the `projectId` query parameter. In a multi-project
daemon (e.g. running from `~/`) sessions belonging to secondary projects were
invisible — list returned empty, fetching by ID returned 404.
Root cause: `registerChatRoutes` accessed `options.chatStore` directly instead of
routing through the per-project `resolveProjectChatContext` helper (already used
correctly by `registerChatRoomRoutes` for the rooms API).
Fix: introduce a `resolveScopedChatStore(projectId)` helper inside
`registerChatRoutes` that delegates to `resolveProjectChatContext`, and replace
all ten `options.chatStore` usages with calls to this helper. When `engineManager`
is present and has an engine for the given `projectId`, the engine's own
`ChatStore` is used; otherwise falls back to the default store (backward compatible).

View File

@@ -505,6 +505,46 @@ describe("Chat API Routes", () => {
expect(enrichedSession.lastMessagePreview).toBeUndefined();
expect(enrichedSession.lastMessageAt).toBeUndefined();
});
it("uses engine chatStore when engineManager is configured for requested projectId", async () => {
const { createServer } = await import("../server.js");
// Engine-scoped chatStore — simulates a secondary project's DB
const engineListSessions = vi.fn().mockReturnValue([sampleSession]);
const engineChatStore = {
...mockChatStoreInstance,
listSessions: engineListSessions,
getLastMessageForSessions: vi.fn().mockReturnValue(new Map()),
};
const mockEngine = { getChatStore: () => engineChatStore };
const mockEngineManager = {
getEngine: vi.fn((id: string) => (id === "proj-secondary" ? mockEngine : undefined)),
getAllEngines: vi.fn().mockReturnValue(new Map([["proj-secondary", mockEngine]])),
};
const appWithEngine = createServer(store as any, {
chatStore: mockChatStore as any,
chatManager: mockChatManager as any,
engineManager: mockEngineManager as any,
});
const response = await request(appWithEngine, "GET", "/api/chat/sessions?projectId=proj-secondary");
expect(response.status).toBe(200);
// Engine chatStore was used, not the default one
expect(engineListSessions).toHaveBeenCalled();
expect(mockListSessions).not.toHaveBeenCalled();
});
it("falls back to default chatStore when no engineManager is configured", async () => {
mockListSessions.mockReturnValue([sampleSession]);
// app has no engineManager (the default setup)
const response = await request(app, "GET", "/api/chat/sessions?projectId=proj-001");
expect(response.status).toBe(200);
expect(mockListSessions).toHaveBeenCalledWith({ projectId: "proj-001" });
});
});
describe("POST /api/chat/sessions", () => {
@@ -684,6 +724,34 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(404);
expect((response.body as any).error).toContain("not found");
});
it("uses engine chatStore to find session when projectId is provided", async () => {
const { createServer } = await import("../server.js");
const engineSession = { ...sampleSession, id: "chat-engine-456", projectId: "proj-secondary" };
const engineGetSession = vi.fn((id: string) => (id === "chat-engine-456" ? engineSession : undefined));
const engineChatStore = { ...mockChatStoreInstance, getSession: engineGetSession };
const mockEngine = { getChatStore: () => engineChatStore };
const mockEngineManager = {
getEngine: vi.fn((id: string) => (id === "proj-secondary" ? mockEngine : undefined)),
getAllEngines: vi.fn().mockReturnValue(new Map([["proj-secondary", mockEngine]])),
};
// Default chatStore does NOT have this session
mockGetSession.mockReturnValue(undefined);
const appWithEngine = createServer(store as any, {
chatStore: mockChatStore as any,
chatManager: mockChatManager as any,
engineManager: mockEngineManager as any,
});
const response = await request(appWithEngine, "GET", "/api/chat/sessions/chat-engine-456?projectId=proj-secondary");
expect(response.status).toBe(200);
expect((response.body as any).session.id).toBe("chat-engine-456");
expect(engineGetSession).toHaveBeenCalledWith("chat-engine-456");
});
});
describe("PATCH /api/chat/sessions/:id", () => {

View File

@@ -4,6 +4,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import type { EnrichedChatSession, ChatAttachment } from "@fusion/core";
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
import { resolveProjectChatContext } from "../chat-project-services.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";
@@ -27,9 +28,18 @@ function resolveAttachmentPath(rootDir: string, sessionId: string, filename: str
}
export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): void {
const { router, options, getProjectContext, chatLogger, rethrowAsApiError } = ctx;
const { router, options, store, getProjectContext, chatLogger, rethrowAsApiError } = ctx;
const { parseLastEventId, replayBufferedSSE, validateOptionalModelField, upload } = deps;
async function resolveScopedChatStore(projectId: string | undefined) {
return resolveProjectChatContext({
projectId,
defaultStore: store,
defaultChatStore: options?.chatStore,
engineManager: options?.engineManager,
});
}
const uploadChatAttachment: import("express").RequestHandler = (req, res, next) => {
upload.single("file")(req, res, (err?: unknown) => {
if (!err) {
@@ -56,11 +66,6 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const { projectId, status, agentId, lookup, modelProvider, modelId } = req.query as {
projectId?: string;
status?: string;
@@ -69,6 +74,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
modelProvider?: string;
modelId?: string;
};
const { chatStore } = await resolveScopedChatStore(projectId);
const isResumeLookup = lookup === "resume";
const hasModelProvider = typeof modelProvider === "string" && modelProvider.trim().length > 0;
@@ -144,13 +150,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
// Get project context to scope the session and resolve agent from the correct store
const { store: scopedStore, projectId } = await getProjectContext(req);
const { chatStore } = await resolveScopedChatStore(projectId);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
@@ -220,10 +222,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.get("/chat/sessions/:id", async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
const session = chatStore.getSession(sessionId);
@@ -250,10 +249,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.patch("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
const { title, status } = req.body as { title?: string; status?: string };
@@ -287,11 +283,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.delete("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const chatStore = options?.chatStore;
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
if (!chatStore) {
throw internalError("Chat store not available");
}
const deleted = chatStore.deleteSession(sessionId);
if (!deleted) {
@@ -314,10 +307,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.get("/chat/sessions/:id/messages", async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
@@ -363,10 +353,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
router.post("/chat/sessions/:id/attachments", rateLimit(RATE_LIMITS.mutation), uploadChatAttachment, async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
const session = chatStore.getSession(sessionId);
@@ -456,10 +443,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 { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const chatManager = options?.chatManager;
if (!chatStore || !chatManager) {
throw internalError("Chat store or manager not available");
if (!chatManager) {
throw internalError("Chat manager not available");
}
const sessionId = String(req.params.id);
@@ -545,10 +532,10 @@ 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 { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const chatManager = options?.chatManager;
if (!chatStore || !chatManager) {
throw internalError("Chat store or manager not available");
if (!chatManager) {
throw internalError("Chat manager not available");
}
const { content, modelProvider, modelId, attachments } = req.body as {
@@ -711,10 +698,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
*/
router.delete("/chat/sessions/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const sessionId = String(req.params.id);
const messageId = String(req.params.messageId);