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:
21
.changeset/fix-chat-sessions-multi-project-scope.md
Normal file
21
.changeset/fix-chat-sessions-multi-project-scope.md
Normal 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).
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user