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.lastMessagePreview).toBeUndefined();
|
||||||
expect(enrichedSession.lastMessageAt).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", () => {
|
describe("POST /api/chat/sessions", () => {
|
||||||
@@ -684,6 +724,34 @@ describe("Chat API Routes", () => {
|
|||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
expect((response.body as any).error).toContain("not found");
|
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", () => {
|
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 { basename, join, resolve } from "node:path";
|
||||||
import type { EnrichedChatSession, ChatAttachment } from "@fusion/core";
|
import type { EnrichedChatSession, ChatAttachment } from "@fusion/core";
|
||||||
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
|
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 { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js";
|
||||||
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
||||||
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.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 {
|
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;
|
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) => {
|
const uploadChatAttachment: import("express").RequestHandler = (req, res, next) => {
|
||||||
upload.single("file")(req, res, (err?: unknown) => {
|
upload.single("file")(req, res, (err?: unknown) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
@@ -56,11 +66,6 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
*/
|
*/
|
||||||
router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => {
|
router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
|
||||||
if (!chatStore) {
|
|
||||||
throw internalError("Chat store not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { projectId, status, agentId, lookup, modelProvider, modelId } = req.query as {
|
const { projectId, status, agentId, lookup, modelProvider, modelId } = req.query as {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -69,6 +74,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
};
|
};
|
||||||
|
const { chatStore } = await resolveScopedChatStore(projectId);
|
||||||
|
|
||||||
const isResumeLookup = lookup === "resume";
|
const isResumeLookup = lookup === "resume";
|
||||||
const hasModelProvider = typeof modelProvider === "string" && modelProvider.trim().length > 0;
|
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) => {
|
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||||
try {
|
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
|
// Get project context to scope the session and resolve agent from the correct store
|
||||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||||
|
const { chatStore } = await resolveScopedChatStore(projectId);
|
||||||
const { AgentStore } = await import("@fusion/core");
|
const { AgentStore } = await import("@fusion/core");
|
||||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
await agentStore.init();
|
await agentStore.init();
|
||||||
@@ -220,10 +222,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
*/
|
*/
|
||||||
router.get("/chat/sessions/:id", async (req, res) => {
|
router.get("/chat/sessions/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
if (!chatStore) {
|
|
||||||
throw internalError("Chat store not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = String(req.params.id);
|
const sessionId = String(req.params.id);
|
||||||
const session = chatStore.getSession(sessionId);
|
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) => {
|
router.patch("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
if (!chatStore) {
|
|
||||||
throw internalError("Chat store not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = String(req.params.id);
|
const sessionId = String(req.params.id);
|
||||||
const { title, status } = req.body as { title?: string; status?: string };
|
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) => {
|
router.delete("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
const sessionId = String(req.params.id);
|
const sessionId = String(req.params.id);
|
||||||
if (!chatStore) {
|
|
||||||
throw internalError("Chat store not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = chatStore.deleteSession(sessionId);
|
const deleted = chatStore.deleteSession(sessionId);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
@@ -314,10 +307,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
|||||||
*/
|
*/
|
||||||
router.get("/chat/sessions/:id/messages", async (req, res) => {
|
router.get("/chat/sessions/:id/messages", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
if (!chatStore) {
|
|
||||||
throw internalError("Chat store not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = String(req.params.id);
|
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) => {
|
router.post("/chat/sessions/:id/attachments", rateLimit(RATE_LIMITS.mutation), uploadChatAttachment, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
if (!chatStore) {
|
|
||||||
throw internalError("Chat store not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = String(req.params.id);
|
const sessionId = String(req.params.id);
|
||||||
const session = chatStore.getSession(sessionId);
|
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) => {
|
router.get("/chat/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
const chatManager = options?.chatManager;
|
const chatManager = options?.chatManager;
|
||||||
if (!chatStore || !chatManager) {
|
if (!chatManager) {
|
||||||
throw internalError("Chat store or manager not available");
|
throw internalError("Chat manager not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = String(req.params.id);
|
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) => {
|
router.post("/chat/sessions/:id/messages", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
const chatManager = options?.chatManager;
|
const chatManager = options?.chatManager;
|
||||||
if (!chatStore || !chatManager) {
|
if (!chatManager) {
|
||||||
throw internalError("Chat store or manager not available");
|
throw internalError("Chat manager not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content, modelProvider, modelId, attachments } = req.body as {
|
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) => {
|
router.delete("/chat/sessions/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatStore = options?.chatStore;
|
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||||
if (!chatStore) {
|
|
||||||
throw internalError("Chat store not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = String(req.params.id);
|
const sessionId = String(req.params.id);
|
||||||
const messageId = String(req.params.messageId);
|
const messageId = String(req.params.messageId);
|
||||||
|
|||||||
Reference in New Issue
Block a user