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:
@@ -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);
|
||||
|
||||
@@ -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]> = [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user