feat(FN-1857): merge fusion/fn-1857

This commit is contained in:
gsxdsm
2026-04-14 18:46:34 -07:00
parent 568c6ddd17
commit fa5cd05078
4 changed files with 340 additions and 5 deletions

View 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");
});
});

View File

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