diff --git a/.changeset/fix-chat-message-persistence-store-divergence.md b/.changeset/fix-chat-message-persistence-store-divergence.md new file mode 100644 index 0000000000..1f3036f3ba --- /dev/null +++ b/.changeset/fix-chat-message-persistence-store-divergence.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix regular chat (ChatView) messages disappearing after leaving and returning to a conversation. The chat message **writer** (`POST /api/chat/sessions/:id/messages`, plus cancel and `isGenerating` enrichment) resolved its per-project `ChatManager`/`ChatStore` through `getOrCreateProjectStore`, while the **reader** (`GET /api/chat/sessions/:id/messages`) resolves through the engine-aware `resolveProjectChatContext`. When those resolved to different store instances, a sent message persisted to one store but the reload read from another, so it vanished on return. The writer now resolves through the same `resolveProjectChatContext` path as the reader, guaranteeing writes and reads share one store. Quick Chat masked the bug by keeping its thread warm in memory (no server reload). diff --git a/packages/dashboard/src/__tests__/chat-routes.test.ts b/packages/dashboard/src/__tests__/chat-routes.test.ts index b8268dc74a..38380eae06 100644 --- a/packages/dashboard/src/__tests__/chat-routes.test.ts +++ b/packages/dashboard/src/__tests__/chat-routes.test.ts @@ -1664,20 +1664,39 @@ describe("multi-project chat routing", () => { vi.restoreAllMocks(); }); - it("POST /cancel uses scoped ChatManager when projectId is provided", async () => { + it("POST /cancel resolves the scoped manager via the engine-aware context (same store as the reader)", async () => { + // FNXC:ChatPersistence regression — the scoped writer (cancel/isGenerating/ + // messages) must resolve through resolveProjectChatContext, the SAME engine- + // aware path the reader uses, so writes and reads land in one store. The old + // writer diverged via getOrCreateProjectStore and dropped messages on reload. mockCancelGeneration.mockReturnValue(false); + const engineChatStore = { ...mockChatStoreInstance }; + const getChatStore = vi.fn(() => engineChatStore); + const getTaskStore = vi.fn(() => store); + const mockEngine = { getChatStore, getTaskStore }; + const getEngine = vi.fn((id: string) => + id === secondarySession.projectId ? mockEngine : undefined, + ); + const { createServer } = await import("../server.js"); + const appWithEngine = createServer(store as any, { + chatStore: mockChatStore as any, + chatManager: mockChatManager as any, + engineManager: { getEngine, getAllEngines: vi.fn().mockReturnValue(new Map()) } as any, + }); const response = await request( - app, + appWithEngine, "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 + // Writer consulted the engine for this project (engine-aware resolution) + expect(getEngine).toHaveBeenCalledWith(secondarySession.projectId); + expect(getChatStore).toHaveBeenCalled(); + // The divergent getOrCreateProjectStore path is no longer used + expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled(); + // cancelGeneration still runs on the scoped manager expect(mockCancelGeneration).toHaveBeenCalledWith(secondarySession.id); }); @@ -1710,8 +1729,9 @@ describe("multi-project chat routing", () => { 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); + // FNXC:ChatPersistence — scoped isGenerating no longer resolves through the + // divergent getOrCreateProjectStore path; it shares the reader's store. + expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled(); // isGenerating defaults to false (MockChatManager has no getGeneratingSessionIds) expect((response.body as any).sessions[0].isGenerating).toBe(false); }); diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 511680c856..414e573304 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -9,8 +9,7 @@ import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attach 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"; +import { getOrCreateScopedChatManager } from "../chat-project-services.js"; interface ChatRouteDeps { parseLastEventId: (req: import("express").Request) => number | undefined; @@ -112,8 +111,25 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): if (!options?.chatManager) throw new ApiError(503, "Chat manager not available"); return options.chatManager; } - const projectStore = await getOrCreateProjectStore(projectId); - const chatStore = getOrCreateScopedChatStore(projectStore); + /* + FNXC:ChatPersistence 2026-06-16-00:00: + The POST /chat/sessions/:id/messages writer MUST resolve the same per-project ChatStore the + GET /chat/sessions/:id/messages reader uses. Otherwise a sent message persists to one store while + the reload reads another, so the message vanishes when the user leaves and returns to the chat. + The reader (resolveScopedChatStore -> resolveProjectChatContext) prefers the engine's per-project + store/chatStore when an engine exists for the project. This writer previously diverged by going + through getOrCreateProjectStore + a fresh getOrCreateScopedChatStore, bypassing the engine, which + could bind the ChatManager to a different store instance than the reader. Resolve both writer and + reader through resolveProjectChatContext so they always share one store. Quick Chat masked this + bug by keeping its thread warm in memory (no server reload); ChatView reloads from the server on + return and surfaced the missing rows. + */ + const { store: projectStore, chatStore } = await resolveProjectChatContext({ + projectId, + defaultStore: store, + defaultChatStore: options?.chatStore, + engineManager: options?.engineManager, + }); return getOrCreateScopedChatManager(projectStore, chatStore, options?.pluginRunner); } // ── Chat Routes ────────────────────────────────────────────────────────────