feat(FN-1857): merge fusion/fn-1857
This commit is contained in:
@@ -121,6 +121,61 @@ describe("useChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads BOTH user and assistant messages when selecting a session", async () => {
|
||||
// This test verifies the fix for FN-1857: Chat assistant messages not persisted
|
||||
// after navigating away. The selectSession should fetch ALL messages from the server,
|
||||
// including both user and assistant messages.
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
// Simulate a conversation with multiple user and assistant messages
|
||||
// Note: The hook calls reverse() on the messages array, so we provide them in reverse order
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
makeMessage({ id: "msg-004", sessionId: "session-001", role: "assistant", content: "Second answer" }),
|
||||
makeMessage({ id: "msg-003", sessionId: "session-001", role: "user", content: "Second question" }),
|
||||
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "First answer" }),
|
||||
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "First question" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toHaveLength(4);
|
||||
});
|
||||
|
||||
// Verify all messages are loaded in correct order
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: "msg-001",
|
||||
role: "user",
|
||||
content: "First question",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
id: "msg-002",
|
||||
role: "assistant",
|
||||
content: "First answer",
|
||||
});
|
||||
expect(result.current.messages[2]).toMatchObject({
|
||||
id: "msg-003",
|
||||
role: "user",
|
||||
content: "Second question",
|
||||
});
|
||||
expect(result.current.messages[3]).toMatchObject({
|
||||
id: "msg-004",
|
||||
role: "assistant",
|
||||
content: "Second answer",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a new session and selects it", async () => {
|
||||
const newSession = makeSession({ id: "session-new", agentId: "agent-001", title: "Test Chat" });
|
||||
mockCreateChatSession.mockResolvedValueOnce({ session: newSession });
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface UseQuickChatReturn {
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
switchSession: (agentId: string) => Promise<void>;
|
||||
loadMessages: () => Promise<void>;
|
||||
reloadMessages: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,11 +115,23 @@ export function useQuickChat(
|
||||
}
|
||||
}, [activeSession, loadMessages]);
|
||||
|
||||
// Reload messages from server (for same-agent revisit)
|
||||
const reloadMessages = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const data = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
|
||||
setMessages(data.messages.reverse());
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to reload messages:", err);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}, [activeSession, projectId]);
|
||||
|
||||
// Switch to a different agent's session
|
||||
const switchSession = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (agentId === currentAgentIdRef.current) return;
|
||||
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
@@ -130,10 +143,17 @@ export function useQuickChat(
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
|
||||
// Initialize session for new agent
|
||||
if (agentId === currentAgentIdRef.current) {
|
||||
// Same agent — just reload messages from server
|
||||
await reloadMessages();
|
||||
return;
|
||||
}
|
||||
|
||||
// New agent — initialize session
|
||||
currentAgentIdRef.current = agentId;
|
||||
await initializeSession(agentId);
|
||||
},
|
||||
[initializeSession],
|
||||
[initializeSession, reloadMessages],
|
||||
);
|
||||
|
||||
// Send a message using SSE streaming
|
||||
@@ -232,5 +252,6 @@ export function useQuickChat(
|
||||
sendMessage,
|
||||
switchSession,
|
||||
loadMessages,
|
||||
reloadMessages,
|
||||
};
|
||||
}
|
||||
|
||||
254
packages/dashboard/src/__tests__/chat-manager.test.ts
Normal file
254
packages/dashboard/src/__tests__/chat-manager.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Tests for ChatManager - specifically text accumulation behavior
|
||||
* These tests verify the fix for FN-1857: Chat assistant messages not persisted after navigating away
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ChatManager, __setCreateKbAgent, __resetChatState } from "../chat.js";
|
||||
|
||||
// ── Mock Store ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mockChatStore = {
|
||||
getSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
getMessages: vi.fn(),
|
||||
};
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ChatManager.sendMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetChatState();
|
||||
|
||||
// Default mock setup
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
});
|
||||
mockChatStore.addMessage.mockReturnValue({
|
||||
id: "msg-001",
|
||||
sessionId: "chat-001",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("accumulates streamed text and uses it for message persistence", async () => {
|
||||
// Track the callbacks to simulate streaming
|
||||
let onThinkingCb: ((delta: string) => void) | undefined;
|
||||
let onTextCb: ((delta: string) => void) | undefined;
|
||||
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
onThinkingCb = options.onThinking;
|
||||
onTextCb = options.onText;
|
||||
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Simulate streaming via callbacks
|
||||
onTextCb?.("Hello ");
|
||||
onTextCb?.("world!");
|
||||
onThinkingCb?.("Let me think...");
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [], // Empty - relying on accumulated text
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Arrange
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
|
||||
// Act
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
// Assert - verify that addMessage was called with accumulated text
|
||||
const assistantCall = mockChatStore.addMessage.mock.calls.find(
|
||||
(call) => call[1].role === "assistant"
|
||||
);
|
||||
expect(assistantCall).toBeDefined();
|
||||
expect(assistantCall?.[1].content).toBe("Hello world!");
|
||||
});
|
||||
|
||||
it("accumulates thinking output separately from text", async () => {
|
||||
let onThinkingCb: ((delta: string) => void) | undefined;
|
||||
let onTextCb: ((delta: string) => void) | undefined;
|
||||
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
onThinkingCb = options.onThinking;
|
||||
onTextCb = options.onText;
|
||||
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
onTextCb?.("Response");
|
||||
onThinkingCb?.("Thinking...");
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [] },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
// Assert - thinking output is accumulated
|
||||
const assistantCall = mockChatStore.addMessage.mock.calls.find(
|
||||
(call) => call[1].role === "assistant"
|
||||
);
|
||||
expect(assistantCall?.[1].thinkingOutput).toBe("Thinking...");
|
||||
});
|
||||
|
||||
it("uses accumulated text as primary source over state.messages extraction", async () => {
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Fire onText callbacks
|
||||
if (options.onText) {
|
||||
options.onText("Accumulated text");
|
||||
}
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{ role: "assistant", content: "State messages text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
// Assert - accumulated text takes precedence
|
||||
const assistantCall = mockChatStore.addMessage.mock.calls.find(
|
||||
(call) => call[1].role === "assistant"
|
||||
);
|
||||
expect(assistantCall?.[1].content).toBe("Accumulated text");
|
||||
});
|
||||
|
||||
it("falls back to state.messages when accumulated text is empty", async () => {
|
||||
__setCreateKbAgent(async () => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Don't fire onText callbacks - rely on state.messages
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{ role: "assistant", content: "Fallback text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
// Assert - falls back to state.messages
|
||||
const assistantCall = mockChatStore.addMessage.mock.calls.find(
|
||||
(call) => call[1].role === "assistant"
|
||||
);
|
||||
expect(assistantCall?.[1].content).toBe("Fallback text");
|
||||
});
|
||||
|
||||
it("handles array content format in state.messages extraction", async () => {
|
||||
__setCreateKbAgent(async () => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// No onText callbacks
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Part1 " },
|
||||
{ type: "text", text: "Part2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
// Assert - array content is joined
|
||||
const assistantCall = mockChatStore.addMessage.mock.calls.find(
|
||||
(call) => call[1].role === "assistant"
|
||||
);
|
||||
expect(assistantCall?.[1].content).toBe("Part1 Part2");
|
||||
});
|
||||
|
||||
it("persists user message before AI response", async () => {
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
if (options.onText) options.onText("Response");
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [] },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
|
||||
await chatManager.sendMessage("chat-001", "User message");
|
||||
|
||||
// Assert - user message is persisted first
|
||||
const calls = mockChatStore.addMessage.mock.calls;
|
||||
expect(calls[0]).toEqual([
|
||||
"chat-001",
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "User message",
|
||||
}),
|
||||
]);
|
||||
// Assistant message is persisted second
|
||||
expect(calls[1][0]).toBe("chat-001");
|
||||
expect(calls[1][1].role).toBe("assistant");
|
||||
});
|
||||
});
|
||||
@@ -317,6 +317,7 @@ export class ChatManager {
|
||||
|
||||
let agentResult: AgentResult | undefined;
|
||||
let accumulatedThinking = "";
|
||||
let accumulatedText = "";
|
||||
|
||||
try {
|
||||
// Ensure engine is loaded
|
||||
@@ -345,6 +346,7 @@ export class ChatManager {
|
||||
});
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
accumulatedText += delta;
|
||||
chatStreamManager.broadcast(sessionId, {
|
||||
type: "text",
|
||||
data: delta,
|
||||
@@ -376,10 +378,13 @@ export class ChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Use accumulated text from streaming (most reliable) with extraction fallback
|
||||
const finalResponseText = accumulatedText || responseText;
|
||||
|
||||
// Persist assistant message
|
||||
const assistantMessage = this.chatStore.addMessage(sessionId, {
|
||||
role: "assistant",
|
||||
content: responseText,
|
||||
content: finalResponseText,
|
||||
thinkingOutput: accumulatedThinking || undefined,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user