Files
zclaw_openfang/desktop/tests/store/chatStore.test.ts
iven 4a23bbeda6
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix: update chatStore tests for sub-store refactoring
Tests were referencing old monolithic useChatStore API. Updated to
use useConversationStore for conversation/agent/model state and
useChatStore for message operations. 10→0 failures.
2026-04-06 11:57:46 +08:00

734 lines
22 KiB
TypeScript

/**
* Chat Store Tests
*
* Tests for chat state management including messages, conversations, and agents.
* After the chatStore refactoring, chatStore delegates to:
* - conversationStore: conversations, agents, currentAgent, sessionKey, currentModel
* - messageStore: token tracking
* - streamStore: streaming, sendMessage, chatMode
*
* chatStore remains the facade, so tests use useChatStore for message operations
* and useConversationStore for conversation/agent state.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useChatStore, Message } from '../../src/store/chatStore';
import { useConversationStore, toChatAgent, type Agent, type Conversation } from '../../src/store/chat/conversationStore';
import { localStorageMock } from '../setup';
// Mock gateway client - use vi.hoisted to ensure mocks are available before module import
const { mockChatStream, mockChat, mockOnAgentStream, mockGetState } = vi.hoisted(() => {
return {
mockChatStream: vi.fn(),
mockChat: vi.fn(),
mockOnAgentStream: vi.fn(() => () => {}),
mockGetState: vi.fn(() => 'disconnected'),
};
});
vi.mock('../../src/lib/gateway-client', () => ({
getGatewayClient: vi.fn(() => ({
chatStream: mockChatStream,
chat: mockChat,
onAgentStream: mockOnAgentStream,
getState: mockGetState,
})),
}));
// Mock intelligence client
vi.mock('../../src/lib/intelligence-client', () => ({
intelligenceClient: {
compactor: {
checkThreshold: vi.fn(() => Promise.resolve({ should_compact: false, current_tokens: 0, urgency: 'none' })),
compact: vi.fn(() => Promise.resolve({ compacted_messages: [] })),
},
memory: {
search: vi.fn(() => Promise.resolve([])),
},
identity: {
buildPrompt: vi.fn(() => Promise.resolve('')),
},
reflection: {
recordConversation: vi.fn(() => Promise.resolve()),
shouldReflect: vi.fn(() => Promise.resolve(false)),
reflect: vi.fn(() => Promise.resolve()),
},
},
}));
// Mock memory extractor
vi.mock('../../src/lib/memory-extractor', () => ({
getMemoryExtractor: vi.fn(() => ({
extractFromConversation: vi.fn(() => Promise.resolve([])),
})),
}));
// Mock agent swarm
vi.mock('../../src/lib/agent-swarm', () => ({
getAgentSwarm: vi.fn(() => ({
createTask: vi.fn(() => ({ id: 'task-1' })),
setExecutor: vi.fn(),
execute: vi.fn(() => Promise.resolve({ summary: 'Task completed', task: { id: 'task-1' } })),
})),
}));
// Mock skill discovery
vi.mock('../../src/lib/skill-discovery', () => ({
getSkillDiscovery: vi.fn(() => ({
searchSkills: vi.fn(() => ({ results: [], totalAvailable: 0 })),
})),
}));
describe('chatStore', () => {
// Default agent matching conversationStore's DEFAULT_AGENT
const defaultAgent: Agent = {
id: '1',
name: 'ZCLAW',
icon: '\u{1F99E}',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: '\u{53D1}\u{9001}\u{6D88}\u{606F}\u{5F00}\u{59CB}\u{5BF9}\u{8BDD}',
time: '',
};
const initialChatState = {
messages: [] as Message[],
isStreaming: false,
isLoading: false,
totalInputTokens: 0,
totalOutputTokens: 0,
chatMode: 'thinking' as const,
suggestions: [] as string[],
};
const initialConvState = {
conversations: [] as Conversation[],
currentConversationId: null as string | null,
agents: [defaultAgent] as Agent[],
currentAgent: defaultAgent as Agent,
isStreaming: false,
currentModel: 'glm-4-flash',
sessionKey: null as string | null,
};
beforeEach(() => {
// Reset chatStore (messages + facade mirrors)
useChatStore.setState(initialChatState);
// Reset conversationStore (conversations, agents, session, model)
useConversationStore.setState(initialConvState);
// Clear localStorage
localStorageMock.clear();
// Clear all mocks
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Initial State', () => {
it('should have empty messages array', () => {
const state = useChatStore.getState();
expect(state.messages).toEqual([]);
});
it('should have default agent set', () => {
const convState = useConversationStore.getState();
expect(convState.currentAgent).not.toBeNull();
expect(convState.currentAgent?.id).toBe('1');
expect(convState.currentAgent?.name).toBe('ZCLAW');
});
it('should not be streaming initially', () => {
const state = useChatStore.getState();
expect(state.isStreaming).toBe(false);
});
it('should have default model', () => {
const convState = useConversationStore.getState();
expect(convState.currentModel).toBe('glm-4-flash');
});
it('should have null sessionKey initially', () => {
const convState = useConversationStore.getState();
expect(convState.sessionKey).toBeNull();
});
it('should have empty conversations array', () => {
const convState = useConversationStore.getState();
expect(convState.conversations).toEqual([]);
});
});
describe('addMessage', () => {
it('should add a message to the store', () => {
const { addMessage } = useChatStore.getState();
const message: Message = {
id: 'test-1',
role: 'user',
content: 'Hello',
timestamp: new Date(),
};
addMessage(message);
const state = useChatStore.getState();
expect(state.messages).toHaveLength(1);
expect(state.messages[0].id).toBe('test-1');
expect(state.messages[0].content).toBe('Hello');
});
it('should append message to existing messages', () => {
const { addMessage } = useChatStore.getState();
addMessage({
id: 'test-1',
role: 'user',
content: 'First',
timestamp: new Date(),
});
addMessage({
id: 'test-2',
role: 'assistant',
content: 'Second',
timestamp: new Date(),
});
const state = useChatStore.getState();
expect(state.messages).toHaveLength(2);
expect(state.messages[0].id).toBe('test-1');
expect(state.messages[1].id).toBe('test-2');
});
it('should preserve message with all fields', () => {
const { addMessage } = useChatStore.getState();
const message: Message = {
id: 'test-1',
role: 'tool',
content: 'Tool output',
timestamp: new Date(),
toolName: 'test-tool',
toolInput: '{"key": "value"}',
toolOutput: 'result',
runId: 'run-123',
};
addMessage(message);
const state = useChatStore.getState();
expect(state.messages[0].toolName).toBe('test-tool');
expect(state.messages[0].toolInput).toBe('{"key": "value"}');
expect(state.messages[0].toolOutput).toBe('result');
expect(state.messages[0].runId).toBe('run-123');
});
});
describe('updateMessage', () => {
it('should update existing message content', () => {
const { addMessage, updateMessage } = useChatStore.getState();
addMessage({
id: 'test-1',
role: 'assistant',
content: 'Initial',
timestamp: new Date(),
});
updateMessage('test-1', { content: 'Updated' });
const state = useChatStore.getState();
expect(state.messages[0].content).toBe('Updated');
});
it('should update streaming flag', () => {
const { addMessage, updateMessage } = useChatStore.getState();
addMessage({
id: 'test-1',
role: 'assistant',
content: 'Streaming...',
timestamp: new Date(),
streaming: true,
});
updateMessage('test-1', { streaming: false });
const state = useChatStore.getState();
expect(state.messages[0].streaming).toBe(false);
});
it('should not modify message if id not found', () => {
const { addMessage, updateMessage } = useChatStore.getState();
addMessage({
id: 'test-1',
role: 'user',
content: 'Test',
timestamp: new Date(),
});
updateMessage('non-existent', { content: 'Should not appear' });
const state = useChatStore.getState();
expect(state.messages[0].content).toBe('Test');
});
it('should update runId on message', () => {
const { addMessage, updateMessage } = useChatStore.getState();
addMessage({
id: 'test-1',
role: 'assistant',
content: 'Test',
timestamp: new Date(),
});
updateMessage('test-1', { runId: 'run-456' });
const state = useChatStore.getState();
expect(state.messages[0].runId).toBe('run-456');
});
});
describe('setCurrentModel', () => {
it('should update current model', () => {
const { setCurrentModel } = useChatStore.getState();
setCurrentModel('gpt-4');
// currentModel is stored on conversationStore
const convState = useConversationStore.getState();
expect(convState.currentModel).toBe('gpt-4');
});
});
describe('newConversation', () => {
it('should clear messages and reset session', () => {
const { addMessage, newConversation } = useChatStore.getState();
addMessage({
id: 'test-1',
role: 'user',
content: 'Test message',
timestamp: new Date(),
});
newConversation();
const chatState = useChatStore.getState();
const convState = useConversationStore.getState();
expect(chatState.messages).toEqual([]);
// sessionKey and currentConversationId reset on conversationStore
expect(convState.sessionKey).toBeNull();
expect(chatState.isStreaming).toBe(false);
expect(convState.currentConversationId).toBeNull();
});
it('should save current messages to conversations before clearing', () => {
const { addMessage, newConversation } = useChatStore.getState();
addMessage({
id: 'test-1',
role: 'user',
content: 'Test message to save',
timestamp: new Date(),
});
newConversation();
const convState = useConversationStore.getState();
// Conversation should be saved on conversationStore
expect(convState.conversations.length).toBeGreaterThan(0);
expect(convState.conversations[0].messages[0].content).toBe('Test message to save');
});
});
describe('switchConversation', () => {
it('should switch to existing conversation', () => {
const { addMessage, switchConversation, newConversation } = useChatStore.getState();
// Create first conversation
addMessage({
id: 'msg-1',
role: 'user',
content: 'First conversation',
timestamp: new Date(),
});
newConversation();
// Create second conversation
addMessage({
id: 'msg-2',
role: 'user',
content: 'Second conversation',
timestamp: new Date(),
});
const convState = useConversationStore.getState();
const firstConvId = convState.conversations[0].id;
// Switch back to first conversation
switchConversation(firstConvId);
const chatState = useChatStore.getState();
const updatedConvState = useConversationStore.getState();
expect(chatState.messages[0].content).toBe('First conversation');
expect(updatedConvState.currentConversationId).toBe(firstConvId);
});
});
describe('deleteConversation', () => {
it('should delete conversation by id', () => {
const { addMessage, newConversation, deleteConversation } = useChatStore.getState();
// Create a conversation
addMessage({
id: 'msg-1',
role: 'user',
content: 'Test',
timestamp: new Date(),
});
newConversation();
const convState = useConversationStore.getState();
const convId = convState.conversations[0].id;
expect(convState.conversations).toHaveLength(1);
// Delete it
deleteConversation(convId);
expect(useConversationStore.getState().conversations).toHaveLength(0);
});
it('should clear messages if deleting current conversation', () => {
const { addMessage, deleteConversation } = useChatStore.getState();
// Add a message
addMessage({
id: 'msg-1',
role: 'user',
content: 'Test',
timestamp: new Date(),
});
// Manually set up a current conversation on conversationStore
const convId = 'conv-test-123';
useConversationStore.setState({
currentConversationId: convId,
conversations: [{
id: convId,
title: 'Test',
messages: useChatStore.getState().messages as any[],
sessionKey: null,
agentId: null,
createdAt: new Date(),
updatedAt: new Date(),
}],
});
deleteConversation(convId);
const chatState = useChatStore.getState();
const convState = useConversationStore.getState();
// chatStore facade should detect resetMessages and clear its messages
expect(chatState.messages).toEqual([]);
expect(convState.sessionKey).toBeNull();
expect(convState.currentConversationId).toBeNull();
});
});
describe('setCurrentAgent', () => {
it('should update current agent', () => {
const { setCurrentAgent } = useChatStore.getState();
const newAgent: Agent = {
id: 'agent-2',
name: 'New Agent',
icon: 'A',
color: 'bg-blue-500',
lastMessage: 'Hello',
time: '',
};
setCurrentAgent(newAgent);
// currentAgent is stored on conversationStore
const convState = useConversationStore.getState();
expect(convState.currentAgent).toEqual(newAgent);
});
it('should save current conversation when switching agents', () => {
const { addMessage, setCurrentAgent } = useChatStore.getState();
// Add a message first
addMessage({
id: 'msg-1',
role: 'user',
content: 'Test message',
timestamp: new Date(),
});
// Switch agent
const newAgent: Agent = {
id: 'agent-2',
name: 'New Agent',
icon: 'A',
color: 'bg-blue-500',
lastMessage: '',
time: '',
};
setCurrentAgent(newAgent);
// Messages should be cleared for new agent (different agent id)
expect(useChatStore.getState().messages).toEqual([]);
});
});
describe('syncAgents', () => {
it('should sync agents from profiles', () => {
const { syncAgents } = useChatStore.getState();
syncAgents([
{ id: 'agent-1', name: 'Agent One', nickname: 'A1' },
{ id: 'agent-2', name: 'Agent Two', nickname: 'A2' },
]);
// agents are stored on conversationStore
const convState = useConversationStore.getState();
// DEFAULT_AGENT + 2 profile agents = 3
expect(convState.agents).toHaveLength(3);
expect(convState.agents[1].name).toBe('Agent One');
expect(convState.agents[2].name).toBe('Agent Two');
});
it('should use default agent when no profiles provided', () => {
const { syncAgents } = useChatStore.getState();
syncAgents([]);
const convState = useConversationStore.getState();
expect(convState.agents).toHaveLength(1);
expect(convState.agents[0].id).toBe('1');
});
});
describe('toChatAgent helper', () => {
it('should convert AgentProfileLike to Agent', () => {
const profile = {
id: 'test-id',
name: 'Test Agent',
nickname: 'Testy',
role: 'Developer',
};
const agent = toChatAgent(profile);
expect(agent.id).toBe('test-id');
expect(agent.name).toBe('Test Agent');
expect(agent.icon).toBe('T');
expect(agent.lastMessage).toBe('Developer');
});
it('should use default icon if no nickname', () => {
const profile = {
id: 'test-id',
name: 'Test Agent',
};
const agent = toChatAgent(profile);
expect(agent.icon).toBe('\u{1F99E}'); // lobster emoji
});
});
describe('searchSkills', () => {
it('should call skill discovery', () => {
const { searchSkills } = useChatStore.getState();
const result = searchSkills('test query');
expect(result).toHaveProperty('results');
expect(result).toHaveProperty('totalAvailable');
});
});
describe('initStreamListener', () => {
it('should return unsubscribe function', () => {
const { initStreamListener } = useChatStore.getState();
const unsubscribe = initStreamListener();
expect(typeof unsubscribe).toBe('function');
unsubscribe();
});
it('should register onAgentStream callback', () => {
const { initStreamListener } = useChatStore.getState();
initStreamListener();
expect(mockOnAgentStream).toHaveBeenCalled();
});
});
describe('sendMessage', () => {
it('should add user message', async () => {
const { sendMessage } = useChatStore.getState();
// Mock gateway as disconnected to use REST fallback
mockGetState.mockReturnValue('disconnected');
mockChat.mockResolvedValue({ response: 'Test response', runId: 'run-1' });
await sendMessage('Hello world');
const state = useChatStore.getState();
// Should have user message and assistant message
expect(state.messages.length).toBeGreaterThanOrEqual(1);
const userMessage = state.messages.find(m => m.role === 'user');
expect(userMessage?.content).toBe('Hello world');
});
it('should set streaming flag while processing', async () => {
const { sendMessage } = useChatStore.getState();
mockGetState.mockReturnValue('disconnected');
mockChat.mockResolvedValue({ response: 'Test response', runId: 'run-1' });
// Start sending (don't await immediately)
const sendPromise = sendMessage('Test');
// Check streaming was set
const streamingDuring = useChatStore.getState().isStreaming;
await sendPromise;
// After completion, streaming should be false
const streamingAfter = useChatStore.getState().isStreaming;
// Streaming was set at some point (either during or reset after)
expect(streamingDuring || !streamingAfter).toBe(true);
});
});
describe('message types', () => {
it('should handle tool message', () => {
const { addMessage } = useChatStore.getState();
const toolMsg: Message = {
id: 'tool-1',
role: 'tool',
content: 'Tool executed',
timestamp: new Date(),
toolName: 'bash',
toolInput: 'echo test',
toolOutput: 'test',
};
addMessage(toolMsg);
const state = useChatStore.getState();
expect(state.messages[0].role).toBe('tool');
expect(state.messages[0].toolName).toBe('bash');
});
it('should handle hand message', () => {
const { addMessage } = useChatStore.getState();
const handMsg: Message = {
id: 'hand-1',
role: 'hand',
content: 'Hand executed',
timestamp: new Date(),
handName: 'browser',
handStatus: 'completed',
handResult: { url: 'https://example.com' },
};
addMessage(handMsg);
const state = useChatStore.getState();
expect(state.messages[0].role).toBe('hand');
expect(state.messages[0].handName).toBe('browser');
});
it('should handle workflow message', () => {
const { addMessage } = useChatStore.getState();
const workflowMsg: Message = {
id: 'workflow-1',
role: 'workflow',
content: 'Workflow step completed',
timestamp: new Date(),
workflowId: 'wf-123',
workflowStep: 'step-1',
workflowStatus: 'completed',
};
addMessage(workflowMsg);
const state = useChatStore.getState();
expect(state.messages[0].role).toBe('workflow');
expect(state.messages[0].workflowId).toBe('wf-123');
});
});
describe('conversation persistence', () => {
it('should derive title from first user message', () => {
const { addMessage, newConversation } = useChatStore.getState();
addMessage({
id: 'msg-1',
role: 'user',
content: 'This is a long message that should be truncated in the title',
timestamp: new Date(),
});
newConversation();
const convState = useConversationStore.getState();
expect(convState.conversations[0].title).toContain('This is a long message');
expect(convState.conversations[0].title.length).toBeLessThanOrEqual(33); // 30 chars + '...'
});
it('should use default title for empty messages', () => {
// Create a conversation directly with empty messages on conversationStore
useConversationStore.setState({
conversations: [{
id: 'conv-1',
title: '',
messages: [],
sessionKey: null,
agentId: null,
createdAt: new Date(),
updatedAt: new Date(),
}],
});
const convState = useConversationStore.getState();
expect(convState.conversations).toHaveLength(1);
});
});
describe('error handling', () => {
it('should handle streaming errors', async () => {
const { addMessage, updateMessage } = useChatStore.getState();
// Add a streaming message
addMessage({
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp: new Date(),
streaming: true,
});
// Simulate error
updateMessage('assistant-1', {
content: 'Error: Connection failed',
streaming: false,
error: 'Connection failed',
});
const state = useChatStore.getState();
expect(state.messages[0].error).toBe('Connection failed');
expect(state.messages[0].streaming).toBe(false);
});
});
});