Files
zclaw_openfang/desktop/tests/store/chatStore.test.ts
iven 0d4fa96b82
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
refactor: 统一项目名称从OpenFang到ZCLAW
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括:
- 配置文件中的项目名称
- 代码注释和文档引用
- 环境变量和路径
- 类型定义和接口名称
- 测试用例和模拟数据

同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
2026-03-27 07:36:03 +08:00

697 lines
20 KiB
TypeScript

/**
* Chat Store Tests
*
* Tests for chat state management including messages, conversations, and agents.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useChatStore, Message, Conversation, Agent, toChatAgent } from '../../src/store/chatStore';
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', () => {
// Store the original state to reset between tests
const initialState = {
messages: [],
conversations: [],
currentConversationId: null,
agents: [{ 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: '' }],
currentAgent: { 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: '' },
isStreaming: false,
currentModel: 'glm-5',
sessionKey: null,
};
beforeEach(() => {
// Reset store state
useChatStore.setState(initialState);
// 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 state = useChatStore.getState();
expect(state.currentAgent).not.toBeNull();
expect(state.currentAgent?.id).toBe('1');
expect(state.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 state = useChatStore.getState();
expect(state.currentModel).toBe('glm-5');
});
it('should have null sessionKey initially', () => {
const state = useChatStore.getState();
expect(state.sessionKey).toBeNull();
});
it('should have empty conversations array', () => {
const state = useChatStore.getState();
expect(state.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');
const state = useChatStore.getState();
expect(state.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(),
});
useChatStore.setState({ sessionKey: 'old-session' });
newConversation();
const state = useChatStore.getState();
expect(state.messages).toEqual([]);
expect(state.sessionKey).toBeNull();
expect(state.isStreaming).toBe(false);
expect(state.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 state = useChatStore.getState();
// Conversation should be saved
expect(state.conversations.length).toBeGreaterThan(0);
expect(state.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 firstConvId = useChatStore.getState().conversations[0].id;
// Switch back to first conversation
switchConversation(firstConvId);
const state = useChatStore.getState();
expect(state.messages[0].content).toBe('First conversation');
expect(state.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 convId = useChatStore.getState().conversations[0].id;
expect(useChatStore.getState().conversations).toHaveLength(1);
// Delete it
deleteConversation(convId);
expect(useChatStore.getState().conversations).toHaveLength(0);
});
it('should clear messages if deleting current conversation', () => {
const { addMessage, deleteConversation } = useChatStore.getState();
// Create a conversation without calling newConversation
addMessage({
id: 'msg-1',
role: 'user',
content: 'Test',
timestamp: new Date(),
});
// Manually set up a current conversation
const convId = 'conv-test-123';
useChatStore.setState({
currentConversationId: convId,
conversations: [{
id: convId,
title: 'Test',
messages: useChatStore.getState().messages,
sessionKey: null,
agentId: null,
createdAt: new Date(),
updatedAt: new Date(),
}],
});
deleteConversation(convId);
const state = useChatStore.getState();
expect(state.messages).toEqual([]);
expect(state.sessionKey).toBeNull();
expect(state.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);
const state = useChatStore.getState();
expect(state.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
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' },
]);
const state = useChatStore.getState();
expect(state.agents).toHaveLength(2);
expect(state.agents[0].name).toBe('Agent One');
expect(state.agents[1].name).toBe('Agent Two');
});
it('should use default agent when no profiles provided', () => {
const { syncAgents } = useChatStore.getState();
syncAgents([]);
const state = useChatStore.getState();
expect(state.agents).toHaveLength(1);
expect(state.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 state = useChatStore.getState();
expect(state.conversations[0].title).toContain('This is a long message');
expect(state.conversations[0].title.length).toBeLessThanOrEqual(33); // 30 chars + '...'
});
it('should use default title for empty messages', () => {
// Create a conversation directly with empty messages
useChatStore.setState({
conversations: [{
id: 'conv-1',
title: '',
messages: [],
sessionKey: null,
agentId: null,
createdAt: new Date(),
updatedAt: new Date(),
}],
});
const state = useChatStore.getState();
expect(state.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);
});
});
});