Files
fusion/packages/dashboard/src/__tests__/chat-manager.test.ts
2026-04-22 02:16:40 -07:00

1157 lines
35 KiB
TypeScript

/**
* 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,
__setBuildAgentChatPrompt,
__setCreateFnAgent,
__resetChatState,
chatStreamManager,
__getChatDiagnostics,
__setChatDiagnostics,
} from "../chat.js";
// ── Mock Setup ──────────────────────────────────────────────────────────────
// Mock summarizeTitle using vi.hoisted so it's available at module hoisting time
const { mockSummarizeTitle } = vi.hoisted(() => ({
mockSummarizeTitle: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
summarizeTitle: mockSummarizeTitle,
}));
// ── Mock Store ──────────────────────────────────────────────────────────────
const mockChatStore = {
getSession: vi.fn(),
createSession: vi.fn(),
addMessage: vi.fn(),
getMessages: vi.fn(),
updateSession: vi.fn(),
};
const mockAgentStore = {
init: vi.fn(),
getAgent: vi.fn(),
listAgents: vi.fn(),
};
function createChatManager(): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any);
}
function createChatManagerWithoutAgentStore(): ChatManager {
return new ChatManager(mockChatStore as any, "/tmp/test");
}
// ── 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: "",
});
mockChatStore.getMessages.mockReturnValue([]);
mockAgentStore.init.mockResolvedValue(undefined);
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be calm and precise.",
memory: "Remember to keep test coverage high.",
instructionsText: "Keep replies focused.",
});
mockAgentStore.listAgents.mockResolvedValue([
{
id: "agent-001",
name: "Avery",
role: "executor",
state: "idle",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
metadata: {},
},
]);
__setBuildAgentChatPrompt(async ({ agent, basePrompt }: any) => {
return [
basePrompt,
`## Soul\n\n${agent.soul ?? ""}`,
`## Memory\n\n${agent.memory ?? ""}`,
`## Instructions\n\n${agent.instructionsText ?? ""}`,
].join("\n\n");
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("mention parsing and context", () => {
it("parseMentions extracts known agent names from content", async () => {
mockAgentStore.listAgents.mockResolvedValue([
{
id: "agent-001",
name: "Alpha",
role: "executor",
state: "idle",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
metadata: {},
},
]);
const chatManager = createChatManager();
const mentions = await (chatManager as any).parseMentions("hello @Alpha how are you");
expect(mentions).toEqual([{ agentId: "agent-001", agentName: "Alpha" }]);
});
it("parseMentions handles underscores in mentions", async () => {
mockAgentStore.listAgents.mockResolvedValue([
{
id: "agent-003",
name: "My Agent",
role: "reviewer",
state: "idle",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
metadata: {},
},
]);
const chatManager = createChatManager();
const mentions = await (chatManager as any).parseMentions("ping @My_Agent please");
expect(mentions).toEqual([{ agentId: "agent-003", agentName: "My Agent" }]);
});
it("parseMentions returns empty array when no mentions are present", async () => {
const chatManager = createChatManager();
const mentions = await (chatManager as any).parseMentions("hello there");
expect(mentions).toEqual([]);
expect(mockAgentStore.listAgents).not.toHaveBeenCalled();
});
it("parseMentions returns empty array when agentStore is unavailable", async () => {
const chatManager = createChatManagerWithoutAgentStore();
const mentions = await (chatManager as any).parseMentions("hello @Alpha");
expect(mentions).toEqual([]);
});
it("buildMentionContext includes agent details", async () => {
mockAgentStore.listAgents.mockResolvedValue([
{
id: "agent-001",
name: "Alpha",
role: "executor",
state: "running",
taskId: "FN-2000",
soul: "A".repeat(260),
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
metadata: {},
},
]);
const chatManager = createChatManager();
const context = await (chatManager as any).buildMentionContext([
{ agentId: "agent-001", agentName: "Alpha" },
]);
expect(context).toContain("The user mentioned the following agents in their message:");
expect(context).toContain("@Alpha");
expect(context).toContain("role: executor");
expect(context).toContain("currently working on: FN-2000");
expect(context).toContain("…");
});
it("buildMentionContext returns empty string when mentions are empty", async () => {
const chatManager = createChatManager();
const context = await (chatManager as any).buildMentionContext([]);
expect(context).toBe("");
});
it("sendMessage appends mention context to system prompt when mentions are present", async () => {
mockAgentStore.listAgents.mockResolvedValue([
{
id: "agent-001",
name: "Avery",
role: "executor",
state: "running",
taskId: "FN-1948",
soul: "Mention-aware executor",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
metadata: {},
},
]);
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "hello @Avery");
expect(createOptions.systemPrompt).toContain("The user mentioned the following agents in their message:");
expect(createOptions.systemPrompt).toContain("@Avery");
expect(createOptions.systemPrompt).toContain("currently working on: FN-1948");
});
it("sendMessage stores mention metadata on the user message", async () => {
mockAgentStore.listAgents.mockResolvedValue([
{
id: "agent-001",
name: "Avery",
role: "executor",
state: "idle",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
metadata: {},
},
]);
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "hello @Avery");
expect(mockChatStore.addMessage).toHaveBeenNthCalledWith(
1,
"chat-001",
expect.objectContaining({
role: "user",
content: "hello @Avery",
metadata: {
mentions: [{ agentId: "agent-001", agentName: "Avery" }],
},
}),
);
});
});
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;
__setCreateFnAgent(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 = createChatManager();
// 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("broadcasts tool_start and tool_end SSE events when agent calls tools", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
let onToolStartCb: ((name: string, args?: Record<string, unknown>) => void) | undefined;
let onToolEndCb: ((name: string, isError: boolean, result?: unknown) => void) | undefined;
__setCreateFnAgent(async (options: any) => {
onToolStartCb = options.onToolStart;
onToolEndCb = options.onToolEnd;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
onToolStartCb?.("read", { path: "/foo.ts" });
onToolEndCb?.("read", false, "file contents");
options.onText?.("Done");
}),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Use read tool");
unsubscribe();
expect(events).toContainEqual({
type: "tool_start",
data: { toolName: "read", args: { path: "/foo.ts" } },
});
expect(events).toContainEqual({
type: "tool_end",
data: { toolName: "read", isError: false, result: "file contents" },
});
});
it("persists tool calls in assistant message metadata", async () => {
let onToolStartCb: ((name: string, args?: Record<string, unknown>) => void) | undefined;
let onToolEndCb: ((name: string, isError: boolean, result?: unknown) => void) | undefined;
__setCreateFnAgent(async (options: any) => {
onToolStartCb = options.onToolStart;
onToolEndCb = options.onToolEnd;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
onToolStartCb?.("read", { path: "foo.ts" });
onToolEndCb?.("read", false, "contents");
options.onText?.("Here you go");
}),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Here you go" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Read foo.ts");
const assistantCall = mockChatStore.addMessage.mock.calls.find(
(call) => call[1].role === "assistant",
);
expect(assistantCall).toBeDefined();
expect(assistantCall?.[1]).toEqual(
expect.objectContaining({
metadata: {
toolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
result: "contents",
},
],
},
}),
);
});
it("creates chat agents with the full coding toolset", async () => {
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.tools).toBe("coding");
});
it("accumulates thinking output separately from text", async () => {
let onThinkingCb: ((delta: string) => void) | undefined;
let onTextCb: ((delta: string) => void) | undefined;
__setCreateFnAgent(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 = createChatManager();
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("persists partial assistant response when AI processing fails after streaming text", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
__setCreateFnAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
options.onThinking?.("Thinking...");
options.onText?.("Partial answer");
throw new Error("Tool execution failed");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
unsubscribe();
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "Partial answer",
thinkingOutput: "Thinking...",
metadata: { interrupted: true },
}),
]);
expect(events).toContainEqual({ type: "error", data: "Tool execution failed" });
});
it("does not persist empty assistant response on immediate failure", async () => {
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("Immediate failure")),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(0);
});
it("persists thinking output even when no text was generated", async () => {
__setCreateFnAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
options.onThinking?.("Working through tools");
throw new Error("Interrupted during tool call");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "(response interrupted before text generation)",
thinkingOutput: "Working through tools",
metadata: { interrupted: true },
}),
]);
});
it("uses accumulated text as primary source over state.messages extraction", async () => {
__setCreateFnAgent(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 = createChatManager();
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 () => {
__setCreateFnAgent(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 = createChatManager();
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 () => {
__setCreateFnAgent(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 = createChatManager();
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 () => {
__setCreateFnAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (options.onText) options.onText("Response");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
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");
});
it("passes enriched system prompt with agent soul when agent context is available", async () => {
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(mockAgentStore.init).toHaveBeenCalledTimes(1);
expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001");
expect(createOptions.systemPrompt).toContain("Be calm and precise.");
});
it("passes enriched system prompt with agent memory when agent context is available", async () => {
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be concise.",
memory: "Remember repo conventions from prior tasks.",
instructionsText: "Focus on correctness.",
});
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.systemPrompt).toContain("Remember repo conventions from prior tasks.");
});
it("falls back to generic chat system prompt when agent lookup returns null", async () => {
mockAgentStore.getAgent.mockResolvedValue(null);
let createOptions: any;
__setCreateFnAgent(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(createOptions.systemPrompt).toContain("You are a helpful AI assistant integrated into the fn task board system.");
expect(createOptions.systemPrompt).not.toContain("## Soul");
});
it("includes previous user and assistant messages in the prompt context", async () => {
const promptSpy = vi.fn().mockResolvedValue(undefined);
mockChatStore.getMessages.mockReturnValue([
{ role: "user", content: "Earlier user question" },
{ role: "assistant", content: "Earlier assistant answer" },
{ role: "system", content: "System note should be filtered" },
{ role: "user", content: "Current question" },
]);
__setCreateFnAgent(async () => {
return {
session: {
prompt: promptSpy,
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Current question");
expect(promptSpy).toHaveBeenCalledTimes(1);
const promptArgument = promptSpy.mock.calls[0]?.[0];
expect(promptArgument).toContain("## Previous Conversation");
expect(promptArgument).toContain("[User]: Earlier user question");
expect(promptArgument).toContain("[Assistant]: Earlier assistant answer");
expect(promptArgument).not.toContain("System note should be filtered");
expect(promptArgument).toContain("## Current Message");
expect(promptArgument).toContain("Current question");
});
it("generates title when session has no title", async () => {
mockSummarizeTitle.mockResolvedValue("Short Title");
__setCreateFnAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (options.onText) options.onText("Response");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "This is a long message that needs to be summarized");
// Wait for the async title generation
await new Promise((resolve) => setTimeout(resolve, 100));
// Assert - summarizeTitle was called with the message content and model params
expect(mockSummarizeTitle).toHaveBeenCalledWith(
"This is a long message that needs to be summarized",
"/tmp/test",
undefined,
undefined,
);
// Assert - session was updated with the generated title
expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", { title: "Short Title" });
});
it("uses truncated content when summarizeTitle returns null", async () => {
mockSummarizeTitle.mockResolvedValue(null);
__setCreateFnAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (options.onText) options.onText("Response");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
const longMessage = "A".repeat(300);
await chatManager.sendMessage("chat-001", longMessage);
// Wait for the async title generation
await new Promise((resolve) => setTimeout(resolve, 100));
// Assert - summarizeTitle was called
expect(mockSummarizeTitle).toHaveBeenCalled();
// Assert - session was updated with truncated content (first 60 chars)
expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", { title: "A".repeat(60) });
});
it("does not generate title when session already has a title", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: "agent-001",
status: "active",
title: "Existing Title",
});
__setCreateFnAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
if (options.onText) options.onText("Response");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "This is a long message");
// Wait for potential async operations
await new Promise((resolve) => setTimeout(resolve, 100));
// Assert - summarizeTitle was NOT called
expect(mockSummarizeTitle).not.toHaveBeenCalled();
// Assert - updateSession was NOT called
expect(mockChatStore.updateSession).not.toHaveBeenCalled();
});
it("cancelGeneration returns false when no active generation exists", () => {
const chatManager = createChatManager();
expect(chatManager.cancelGeneration("chat-001")).toBe(false);
});
it("cancelGeneration returns true and aborts an active generation", () => {
const chatManager = createChatManager();
const abortController = new AbortController();
const dispose = vi.fn();
(chatManager as any).activeGenerations.set("chat-001", {
abortController,
agentResult: { session: { dispose } },
});
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
const result = chatManager.cancelGeneration("chat-001");
unsubscribe();
expect(result).toBe(true);
expect(abortController.signal.aborted).toBe(true);
expect(dispose).toHaveBeenCalledTimes(1);
expect(events).toContainEqual({ type: "error", data: "Generation cancelled" });
});
it("cancelled generation does not persist assistant message", async () => {
let rejectPrompt: ((reason?: unknown) => void) | undefined;
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(() => {
return new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
});
}),
dispose: vi.fn().mockImplementation(() => {
rejectPrompt?.(new Error("Disposed"));
}),
state: {
messages: [{ role: "assistant", content: "Should not persist" }],
},
},
};
});
const chatManager = createChatManager();
const sendPromise = chatManager.sendMessage("chat-001", "Hello");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(chatManager.cancelGeneration("chat-001")).toBe(true);
await sendPromise;
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(0);
});
it("cancelled generation broadcasts error event with cancellation message", async () => {
let rejectPrompt: ((reason?: unknown) => void) | undefined;
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(() => {
return new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
});
}),
dispose: vi.fn().mockImplementation(() => {
rejectPrompt?.(new Error("Disposed"));
}),
state: { messages: [] },
},
};
});
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
const chatManager = createChatManager();
const sendPromise = chatManager.sendMessage("chat-001", "Hello");
await new Promise((resolve) => setTimeout(resolve, 0));
chatManager.cancelGeneration("chat-001");
await sendPromise;
unsubscribe();
expect(events.some((event) => event.type === "error" && event.data === "Generation cancelled")).toBe(true);
});
it("cleans active generation state even when dispose fails", async () => {
const disposeSpy = vi.fn().mockImplementation(() => {
throw new Error("dispose failed");
});
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: disposeSpy,
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect((chatManager as any).activeGenerations.has("chat-001")).toBe(false);
});
});
describe("ChatManager diagnostics", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetChatState();
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: "agent-001",
status: "active",
});
mockChatStore.addMessage.mockReturnValue({
id: "msg-001",
sessionId: "chat-001",
role: "assistant",
content: "",
});
mockChatStore.getMessages.mockReturnValue([]);
mockAgentStore.init.mockResolvedValue(undefined);
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be calm and precise.",
});
mockAgentStore.listAgents.mockResolvedValue([]);
__setBuildAgentChatPrompt(async ({ basePrompt }: any) => basePrompt);
});
it("logs error diagnostic when broadcast callback throws", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const throwingCallback = vi.fn(() => {
throw new Error("Broadcast callback failed");
});
chatStreamManager.subscribe("chat-001", throwingCallback);
expect(() =>
chatStreamManager.broadcast("chat-001", { type: "thinking", data: "test" })
).not.toThrow();
expect(throwingCallback).toHaveBeenCalledTimes(1);
expect(loggedErrors).toContainEqual({
message: "Error broadcasting to client for session chat-001:",
args: [expect.any(Error)],
});
});
it("logs error diagnostic when sendMessage encounters AI processing failure", async () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("AI processing failed")),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(loggedErrors).toContainEqual({
message: "Error in sendMessage for session chat-001:",
args: [expect.any(Error)],
});
});
it("logs error diagnostic when dispose fails during cancellation", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const disposeSpy = vi.fn().mockImplementation(() => {
throw new Error("Dispose failed");
});
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: disposeSpy,
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
// Set up an active generation manually
const abortController = new AbortController();
(chatManager as any).activeGenerations.set("chat-001", {
abortController,
agentResult: { session: { dispose: disposeSpy } },
});
chatManager.cancelGeneration("chat-001");
expect(loggedErrors).toContainEqual({
message: "Error disposing agent session during cancellation:",
args: [expect.any(Error)],
});
});
it("logs error diagnostic when dispose fails after successful sendMessage", async () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const disposeSpy = vi.fn().mockImplementation(() => {
throw new Error("Dispose failed");
});
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: disposeSpy,
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(loggedErrors).toContainEqual({
message: "Error disposing agent session:",
args: [expect.any(Error)],
});
});
});