feat(FN-3212): remove runtime memory-backend side-load, add regression test

Merged FN-3207, FN-3212, and FN-3242: removed runtime memory-backend side-loading in core, added comprehensive regression tests for QuickChat, chat routes, and SSE streams, and documented compact mobile chat dialogs in the dashboard guide. The refactor in `project-memory.ts` reduces complexity while

Fusion-Task-Id: FN-3212
This commit is contained in:
Fusion
2026-05-03 07:32:04 -07:00
committed by gsxdsm
parent 3878132c4e
commit 0948be42fd
6 changed files with 182 additions and 0 deletions

View File

@@ -317,6 +317,38 @@ describe("QuickChatFAB session-first UX", () => {
expect(mockCreateChatSession).not.toHaveBeenCalled();
});
it("shows streaming feedback on second turn after first turn completes", async () => {
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: vi.fn(), isConnected: () => true };
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "Turn one" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
expect(await screen.findByTestId("quick-chat-streaming-message")).toBeInTheDocument();
handlers[0]?.onDone?.({ messageId: "msg-1" });
await waitFor(() => {
expect(screen.queryByTestId("quick-chat-streaming-message")).toBeNull();
});
fireEvent.change(input, { target: { value: "Turn two" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
expect(await screen.findByTestId("quick-chat-streaming-message")).toBeInTheDocument();
expect(screen.getByTestId("quick-chat-waiting")).toHaveTextContent("Connecting…");
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
});
it("shows the streaming indicator instead of the loading placeholder while waiting for a long reply", async () => {
const deferredMessages = createDeferredPromise<{ messages: never[] }>();
mockFetchChatMessages.mockImplementation(() => deferredMessages.promise);

View File

@@ -462,6 +462,55 @@ describe("useQuickChat", () => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
});
it("starts a fresh stream and shows active state on second turn after first turn completes", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, nextHandlers) => {
handlers.push(nextHandlers);
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
void result.current.sendMessage("Turn 1");
});
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
act(() => {
handlers[0]?.onDone?.({ messageId: "msg-001" });
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
});
act(() => {
void result.current.sendMessage("Turn 2");
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(result.current.isStreaming).toBe(true);
});
act(() => {
handlers[1]?.onError?.("second turn failed");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
});
});
it("queued message is auto-sent after streaming onDone", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const handlers: Array<Parameters<typeof mockStreamChatResponse>[2]> = [];

View File

@@ -821,6 +821,42 @@ describe("ChatManager.sendMessage", () => {
expect(createSpy.mock.calls[0]?.[0]?.sessionManager).toBeDefined();
});
it("reopens the same CLI session on second turn and persists both assistant replies", async () => {
const promptSpy = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
mockChatStore.getSession
.mockReturnValueOnce({
id: "chat-001",
agentId: "agent-001",
status: "active",
cliSessionFile: null,
})
.mockReturnValueOnce({
id: "chat-001",
agentId: "agent-001",
status: "active",
cliSessionFile: __dirname + "/chat-manager.test.ts",
});
__setCreateFnAgent(async () => ({
session: { prompt: promptSpy, dispose: vi.fn(), state: { messages: [{ role: "assistant", content: "Done" }] } },
}));
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Turn one");
await chatManager.sendMessage("chat-001", "Turn two");
expect(mockSessionManagerCreate).toHaveBeenCalledTimes(1);
expect(mockSessionManagerOpen).toHaveBeenCalledTimes(1);
expect(promptSpy).toHaveBeenCalledTimes(2);
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(2);
});
it("reopens the same CLI session on subsequent turns instead of creating a new one", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-001",

View File

@@ -928,6 +928,35 @@ describe("Chat API Routes", () => {
// ── SSE Streaming Tests ────────────────────────────────────────────────────
describe("POST /api/chat/sessions/:id/messages (SSE)", () => {
it("creates a fresh backend send for second turn on same session", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockSendMessage.mockImplementation(async (sessionId: string) => {
mockChatStreamManager.broadcast(sessionId, {
type: "done",
data: { messageId: `msg-${Date.now()}` },
});
});
const first = await request(
app,
"POST",
"/api/chat/sessions/chat-abc123/messages",
JSON.stringify({ content: "Turn 1" }),
{ "content-type": "application/json" },
);
const second = await request(
app,
"POST",
"/api/chat/sessions/chat-abc123/messages",
JSON.stringify({ content: "Turn 2" }),
{ "content-type": "application/json" },
);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
});
it("returns 404 when session not found", async () => {
mockGetSession.mockReturnValue(undefined);

View File

@@ -713,6 +713,17 @@ describe("resume session flag", () => {
expect(args).not.toContain("--resume");
});
it("does not include --session-id when --resume is provided", () => {
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
resumeSessionId: "session-abc",
newSessionId: "session-new",
});
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--resume");
expect(args).not.toContain("--session-id");
});
it("includes both --resume and --effort when both are provided", () => {
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
resumeSessionId: "session-abc",

View File

@@ -1512,6 +1512,31 @@ describe("streamViaCli", { timeout: 90_000 }, () => {
});
});
describe("resume behavior", () => {
it("uses --resume for follow-up quick-chat turns when sessionId is present", async () => {
const model = mockModels[0] as any;
const context = {
messages: [
{ role: "user", content: "Turn 1" },
{ role: "assistant", content: "Reply 1" },
{ role: "user", content: "Follow-up" },
],
};
streamViaCli(model, context, { sessionId: "session-follow-up" } as any);
await vi.advanceTimersByTimeAsync(0);
const args = (spawn as any).mock.calls[0][1] as string[];
expect(args).toContain("--resume");
expect(args).toContain("session-follow-up");
expect(args).not.toContain("--session-id");
const proc = (spawn as any).mock.results[0].value;
proc.stdout.end();
await vi.advanceTimersByTimeAsync(100);
});
});
describe("MCP config with custom tool results", () => {
it("keeps MCP config even when conversation ends with custom tool result", async () => {
const model = mockModels[0] as any;