fix(chat): align message writer store resolution with reader

The POST /chat/sessions/:id/messages writer (and cancel + isGenerating
enrichment) resolved its per-project ChatManager/ChatStore via
getOrCreateProjectStore, while the GET reader resolves via 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
regular chat (ChatView) messages vanished after leaving and returning.
Quick Chat masked it by keeping its thread warm in memory (no server reload).

resolveScopedChatManager now resolves through resolveProjectChatContext so
writer and reader always share one store. Updated multi-project routing tests
to the corrected invariant and added an engine-aware regression test.
This commit is contained in:
gsxdsm
2026-06-16 18:09:55 -07:00
parent 319236421d
commit 802fecb245
3 changed files with 53 additions and 12 deletions

View File

@@ -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).

View File

@@ -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);
});

View File

@@ -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 ────────────────────────────────────────────────────────────