UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

1,210 lines (987 loc) 42 kB
import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LOADING_FLAT } from '@/const/message'; import { chatService } from '@/services/chat'; import { messageService } from '@/services/message'; import { chatSelectors } from '@/store/chat/selectors'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { UploadFileItem } from '@/types/files/upload'; import { useChatStore } from '../../../../store'; import { TEST_CONTENT, TEST_IDS, createMockMessage, createMockMessages } from './fixtures'; import { resetTestEnvironment, setupMockSelectors, setupStoreWithMessages, spyOnChatService, spyOnMessageService, } from './helpers'; // Keep zustand mock as it's needed globally vi.mock('zustand/traditional'); const realCoreProcessMessage = useChatStore.getState().internal_coreProcessMessage; beforeEach(() => { resetTestEnvironment(); setupMockSelectors(); // Setup default spies that most tests need spyOnMessageService(); // ✅ Removed spyOnChatService() - tests should spy chatService only when needed // Setup common mock methods that most tests need act(() => { useChatStore.setState({ refreshMessages: vi.fn(), refreshTopic: vi.fn(), internal_coreProcessMessage: vi.fn(), }); }); }); afterEach(() => { process.env.NEXT_PUBLIC_BASE_PATH = undefined; vi.restoreAllMocks(); }); describe('chatMessage actions', () => { describe('sendMessage', () => { describe('validation', () => { it('should not send when there is no active session', async () => { act(() => { useChatStore.setState({ activeId: undefined }); }); const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); }); expect(messageService.createMessage).not.toHaveBeenCalled(); expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled(); }); it('should not send when message is empty and no files are provided', async () => { const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.EMPTY }); }); expect(messageService.createMessage).not.toHaveBeenCalled(); }); it('should not send when message is empty with empty files array', async () => { const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.EMPTY, files: [] }); }); expect(messageService.createMessage).not.toHaveBeenCalled(); }); }); describe('message creation', () => { it('should create user message and trigger AI processing', async () => { const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); }); expect(messageService.createMessage).toHaveBeenCalledWith({ content: TEST_CONTENT.USER_MESSAGE, files: undefined, role: 'user', sessionId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID, }); expect(result.current.internal_coreProcessMessage).toHaveBeenCalled(); }); it('should send message with files attached', async () => { const { result } = renderHook(() => useChatStore()); const files = [{ id: TEST_IDS.FILE_ID } as UploadFileItem]; await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE, files }); }); expect(messageService.createMessage).toHaveBeenCalledWith({ content: TEST_CONTENT.USER_MESSAGE, files: [TEST_IDS.FILE_ID], role: 'user', sessionId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID, }); }); it('should send files without message content', async () => { const { result } = renderHook(() => useChatStore()); const files = [{ id: TEST_IDS.FILE_ID } as UploadFileItem]; await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.EMPTY, files }); }); expect(messageService.createMessage).toHaveBeenCalledWith({ content: TEST_CONTENT.EMPTY, files: [TEST_IDS.FILE_ID], role: 'user', sessionId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID, }); }); it('should not process AI when onlyAddUserMessage is true', async () => { const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE, onlyAddUserMessage: true, }); }); expect(messageService.createMessage).toHaveBeenCalled(); expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled(); }); it('should handle message creation errors gracefully', async () => { const { result } = renderHook(() => useChatStore()); vi.spyOn(messageService, 'createMessage').mockRejectedValue( new Error('create message error'), ); await act(async () => { try { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); } catch { // Expected to throw } }); expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled(); }); }); describe('auto-create topic', () => { const TOPIC_THRESHOLD = 5; it('should create topic when threshold is reached and feature is enabled', async () => { const { result } = renderHook(() => useChatStore()); const createTopicMock = vi.fn(() => Promise.resolve(TEST_IDS.NEW_TOPIC_ID)); const switchTopicMock = vi.fn(); act(() => { setupMockSelectors({ agentConfig: { enableAutoCreateTopic: true, autoCreateTopicThreshold: TOPIC_THRESHOLD, }, }); useChatStore.setState({ activeTopicId: undefined, messagesMap: { [messageMapKey(TEST_IDS.SESSION_ID)]: createMockMessages(TOPIC_THRESHOLD), }, createTopic: createTopicMock, switchTopic: switchTopicMock, }); }); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); }); expect(createTopicMock).toHaveBeenCalled(); expect(switchTopicMock).toHaveBeenCalledWith(TEST_IDS.NEW_TOPIC_ID, true); }); }); describe('RAG integration', () => { it('should include RAG query when RAG is enabled', async () => { const { result } = renderHook(() => useChatStore()); vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(true); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.RAG_QUERY }); }); expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith( expect.any(Array), expect.any(String), expect.objectContaining({ ragQuery: TEST_CONTENT.RAG_QUERY, }), ); }); it('should not use RAG when feature is disabled', async () => { const { result } = renderHook(() => useChatStore()); vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(false); const retrieveChunksSpy = vi.spyOn(result.current, 'internal_retrieveChunks'); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); }); expect(retrieveChunksSpy).not.toHaveBeenCalled(); expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith( expect.any(Array), expect.any(String), expect.not.objectContaining({ ragQuery: expect.anything(), }), ); }); }); describe('special flags', () => { it('should pass isWelcomeQuestion flag to processing', async () => { const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE, isWelcomeQuestion: true, }); }); expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith( expect.anything(), expect.anything(), { isWelcomeQuestion: true }, ); }); it('should return early when onlyAddUserMessage is true', async () => { const { result } = renderHook(() => useChatStore()); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE, onlyAddUserMessage: true, }); }); expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled(); }); }); describe('topic creation flow', () => { it('should handle tempMessage during topic creation', async () => { setupMockSelectors({ chatConfig: { enableAutoCreateTopic: true, autoCreateTopicThreshold: 2 }, }); act(() => { useChatStore.setState({ activeTopicId: undefined }); setupStoreWithMessages(createMockMessages(5)); }); const { result } = renderHook(() => useChatStore()); const createTopicMock = vi .spyOn(result.current, 'createTopic') .mockResolvedValue(TEST_IDS.NEW_TOPIC_ID); const toggleMessageLoadingSpy = vi.spyOn(result.current, 'internal_toggleMessageLoading'); const createTmpMessageSpy = vi .spyOn(result.current, 'internal_createTmpMessage') .mockReturnValue('temp-id'); vi.spyOn(result.current, 'internal_fetchMessages').mockResolvedValue(); vi.spyOn(result.current, 'switchTopic').mockResolvedValue(); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); }); expect(createTmpMessageSpy).toHaveBeenCalled(); expect(toggleMessageLoadingSpy).toHaveBeenCalledWith(true, 'temp-id'); expect(createTopicMock).toHaveBeenCalled(); }); it('should call summaryTopicTitle after processing when new topic created', async () => { setupMockSelectors({ chatConfig: { enableAutoCreateTopic: true, autoCreateTopicThreshold: 2 }, }); act(() => { useChatStore.setState({ activeTopicId: undefined }); setupStoreWithMessages(createMockMessages(5)); }); const { result } = renderHook(() => useChatStore()); vi.spyOn(result.current, 'createTopic').mockResolvedValue(TEST_IDS.NEW_TOPIC_ID); vi.spyOn(result.current, 'internal_createTmpMessage').mockReturnValue('temp-id'); vi.spyOn(result.current, 'internal_fetchMessages').mockResolvedValue(); vi.spyOn(result.current, 'switchTopic').mockResolvedValue(); const summaryTopicTitleSpy = vi .spyOn(result.current, 'summaryTopicTitle') .mockResolvedValue(); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); }); expect(summaryTopicTitleSpy).toHaveBeenCalledWith(TEST_IDS.NEW_TOPIC_ID, expect.any(Array)); }); it('should handle topic creation failure gracefully', async () => { setupMockSelectors({ chatConfig: { enableAutoCreateTopic: true, autoCreateTopicThreshold: 2 }, }); act(() => { useChatStore.setState({ activeTopicId: undefined }); setupStoreWithMessages(createMockMessages(5)); }); const { result } = renderHook(() => useChatStore()); vi.spyOn(result.current, 'createTopic').mockResolvedValue(undefined); vi.spyOn(result.current, 'internal_createTmpMessage').mockReturnValue('temp-id'); const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleMessageLoading'); const updateTopicLoadingSpy = vi.spyOn(result.current, 'internal_updateTopicLoading'); await act(async () => { await result.current.sendMessage({ message: TEST_CONTENT.USER_MESSAGE }); }); // Should still call the AI processing even if topic creation fails expect(result.current.internal_coreProcessMessage).toHaveBeenCalled(); expect(updateTopicLoadingSpy).not.toHaveBeenCalled(); }); }); }); describe('regenerateMessage', () => { it('should trigger message regeneration', async () => { const { result } = renderHook(() => useChatStore()); const traceId = 'test-trace-id'; act(() => { setupStoreWithMessages([ createMockMessage({ id: TEST_IDS.MESSAGE_ID, tools: [{ id: 'tool1' }, { id: 'tool2' }] as any, traceId, }), ]); }); const resendMessageSpy = vi.spyOn(result.current, 'internal_resendMessage'); await act(async () => { await result.current.regenerateMessage(TEST_IDS.MESSAGE_ID); }); expect(resendMessageSpy).toHaveBeenCalledWith( TEST_IDS.MESSAGE_ID, expect.objectContaining({}), ); }); }); describe('delAndRegenerateMessage', () => { it('should delete message then regenerate', async () => { const { result } = renderHook(() => useChatStore()); act(() => { setupStoreWithMessages([ createMockMessage({ id: TEST_IDS.MESSAGE_ID, tools: [{ id: 'tool1' }] as any, }), ]); }); const deleteMessageSpy = vi.spyOn(result.current, 'deleteMessage'); const resendMessageSpy = vi.spyOn(result.current, 'internal_resendMessage'); await act(async () => { await result.current.delAndRegenerateMessage(TEST_IDS.MESSAGE_ID); }); expect(deleteMessageSpy).toHaveBeenCalledWith(TEST_IDS.MESSAGE_ID); expect(resendMessageSpy).toHaveBeenCalled(); }); }); describe('stopGenerateMessage', () => { it('should abort generation and clear loading state when controller exists', () => { const abortController = new AbortController(); act(() => { useChatStore.setState({ chatLoadingIdsAbortController: abortController }); }); const { result } = renderHook(() => useChatStore()); const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading'); act(() => { result.current.stopGenerateMessage(); }); expect(abortController.signal.aborted).toBe(true); expect(toggleLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String)); }); it('should do nothing when abort controller is not set', () => { act(() => { useChatStore.setState({ chatLoadingIdsAbortController: undefined }); }); const { result } = renderHook(() => useChatStore()); const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading'); act(() => { result.current.stopGenerateMessage(); }); expect(toggleLoadingSpy).not.toHaveBeenCalled(); }); }); describe('internal_coreProcessMessage', () => { it('should process user message and generate AI response', async () => { act(() => { useChatStore.setState({ internal_coreProcessMessage: realCoreProcessMessage }); }); const { result } = renderHook(() => useChatStore()); const userMessage = createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user', content: TEST_CONTENT.USER_MESSAGE, }); // ✅ Spy the direct dependency instead of chatService const fetchAIChatSpy = vi .spyOn(result.current, 'internal_fetchAIChatMessage') .mockResolvedValue({ isFunctionCall: false, content: 'AI response' }); const createMessageSpy = vi .spyOn(messageService, 'createMessage') .mockResolvedValue(TEST_IDS.ASSISTANT_MESSAGE_ID); await act(async () => { await result.current.internal_coreProcessMessage([userMessage], userMessage.id); }); expect(createMessageSpy).toHaveBeenCalledWith( expect.objectContaining({ role: 'assistant', content: LOADING_FLAT, parentId: TEST_IDS.USER_MESSAGE_ID, sessionId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID, }), ); expect(fetchAIChatSpy).toHaveBeenCalled(); expect(result.current.refreshMessages).toHaveBeenCalled(); }); it('should handle RAG flow when ragQuery is provided', async () => { act(() => { useChatStore.setState({ internal_coreProcessMessage: realCoreProcessMessage }); }); const { result } = renderHook(() => useChatStore()); const userMessage = createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user', content: TEST_CONTENT.RAG_QUERY, }); const retrieveChunksSpy = vi .spyOn(result.current, 'internal_retrieveChunks') .mockResolvedValue({ chunks: [{ id: 'chunk-1', similarity: 0.9, text: 'chunk text' }] as any, queryId: 'query-1', rewriteQuery: 'rewritten query', }); vi.spyOn(messageService, 'createMessage').mockResolvedValue(TEST_IDS.ASSISTANT_MESSAGE_ID); await act(async () => { await result.current.internal_coreProcessMessage([userMessage], userMessage.id, { ragQuery: TEST_CONTENT.RAG_QUERY, }); }); expect(retrieveChunksSpy).toHaveBeenCalledWith( TEST_IDS.USER_MESSAGE_ID, TEST_CONTENT.RAG_QUERY, [], ); }); it('should not process when createMessage fails', async () => { act(() => { useChatStore.setState({ internal_coreProcessMessage: realCoreProcessMessage }); }); const { result } = renderHook(() => useChatStore()); const userMessage = createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user', }); // ✅ Spy the direct dependency instead of chatService const fetchAIChatSpy = vi .spyOn(result.current, 'internal_fetchAIChatMessage') .mockResolvedValue({ isFunctionCall: false, content: '' }); vi.spyOn(messageService, 'createMessage').mockResolvedValue(undefined as any); await act(async () => { await result.current.internal_coreProcessMessage([userMessage], userMessage.id); }); expect(fetchAIChatSpy).not.toHaveBeenCalled(); }); }); describe('internal_fetchAIChatMessage', () => { it('should fetch and return AI chat response', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; // ✅ Mock chatService instead of global fetch const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onMessageHandle, onFinish }) => { // Simulate text chunks streaming await onMessageHandle?.({ type: 'text', text: TEST_CONTENT.AI_RESPONSE } as any); await onFinish?.(TEST_CONTENT.AI_RESPONSE, {}); }); await act(async () => { const response = await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); expect(response.isFunctionCall).toEqual(false); expect(response.content).toEqual(TEST_CONTENT.AI_RESPONSE); }); streamSpy.mockRestore(); }); it('should handle streaming errors gracefully', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; // ✅ Mock chatService to simulate error const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onErrorHandle }) => { await onErrorHandle?.({ type: 'InvalidProviderAPIKey', message: 'Network error' } as any); }); const updateMessageErrorSpy = vi.spyOn(messageService, 'updateMessageError'); await act(async () => { await result.current.internal_fetchAIChatMessage({ model: 'gpt-4o-mini', provider: 'openai', messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, }); }); expect(updateMessageErrorSpy).toHaveBeenCalledWith( TEST_IDS.ASSISTANT_MESSAGE_ID, expect.objectContaining({ type: 'InvalidProviderAPIKey' }), ); streamSpy.mockRestore(); }); it('should handle tool call chunks during streaming', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onMessageHandle, onFinish }) => { await onMessageHandle?.({ type: 'tool_calls', isAnimationActives: [true], tool_calls: [ { id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } }, ], } as any); await onFinish?.(TEST_CONTENT.AI_RESPONSE, { toolCalls: [ { id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } }, ], } as any); }); await act(async () => { const response = await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); expect(response.isFunctionCall).toEqual(true); }); streamSpy.mockRestore(); }); it('should handle text chunks during streaming', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage'); const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onMessageHandle, onFinish }) => { await onMessageHandle?.({ type: 'text', text: 'Hello' } as any); await onMessageHandle?.({ type: 'text', text: ' World' } as any); await onFinish?.('Hello World', {} as any); }); await act(async () => { await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); }); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, type: 'updateMessage', value: expect.objectContaining({ content: 'Hello' }), }), ); streamSpy.mockRestore(); }); it('should handle reasoning chunks during streaming', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage'); const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onMessageHandle, onFinish }) => { await onMessageHandle?.({ type: 'reasoning', text: 'Thinking...' } as any); await onMessageHandle?.({ type: 'text', text: 'Answer' } as any); await onFinish?.('Answer', {} as any); }); await act(async () => { await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); }); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, type: 'updateMessage', value: expect.objectContaining({ reasoning: { content: 'Thinking...' } }), }), ); streamSpy.mockRestore(); }); it('should skip grounding when citations are empty', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage'); const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onMessageHandle, onFinish }) => { await onMessageHandle?.({ type: 'grounding', grounding: { citations: [], searchQueries: [] }, } as any); await onFinish?.('Answer', {} as any); }); await act(async () => { await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); }); // Should not dispatch when citations are empty const groundingCalls = dispatchSpy.mock.calls.filter((call) => { const dispatch = call[0]; return dispatch?.type === 'updateMessage' && 'value' in dispatch && dispatch.value?.search; }); expect(groundingCalls).toHaveLength(0); streamSpy.mockRestore(); }); it('should handle grounding chunks during streaming', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage'); const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onMessageHandle, onFinish }) => { await onMessageHandle?.({ type: 'grounding', grounding: { citations: [{ url: 'https://example.com', title: 'Example' }], searchQueries: ['test query'], }, } as any); await onFinish?.('Answer', {} as any); }); await act(async () => { await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); }); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, type: 'updateMessage', value: expect.objectContaining({ search: expect.objectContaining({ citations: expect.any(Array), }), }), }), ); streamSpy.mockRestore(); }); it('should handle base64 image chunks during streaming', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage'); const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onMessageHandle, onFinish }) => { await onMessageHandle?.({ type: 'base64_image', image: { id: 'img-1', data: 'base64data' }, images: [{ id: 'img-1', data: 'base64data' }], } as any); await onFinish?.('Answer', {} as any); }); await act(async () => { await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); }); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, type: 'updateMessage', value: expect.objectContaining({ imageList: expect.any(Array), }), }), ); streamSpy.mockRestore(); }); it('should handle empty tool call arguments', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onFinish }) => { await onFinish?.(TEST_CONTENT.AI_RESPONSE, { toolCalls: [ { id: 'tool-1', type: 'function', function: { name: 'test', arguments: '' } }, ], } as any); }); await act(async () => { const response = await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); expect(response.isFunctionCall).toEqual(true); }); streamSpy.mockRestore(); }); it('should update message with traceId when provided in onFinish', async () => { const { result } = renderHook(() => useChatStore()); const messages = [createMockMessage({ role: 'user' })]; const traceId = 'test-trace-123'; const updateMessageSpy = vi.spyOn(messageService, 'updateMessage'); const streamSpy = vi .spyOn(chatService, 'createAssistantMessageStream') .mockImplementation(async ({ onFinish }) => { await onFinish?.(TEST_CONTENT.AI_RESPONSE, { traceId } as any); }); await act(async () => { await result.current.internal_fetchAIChatMessage({ messages, messageId: TEST_IDS.ASSISTANT_MESSAGE_ID, model: 'gpt-4o-mini', provider: 'openai', }); }); expect(updateMessageSpy).toHaveBeenCalledWith( TEST_IDS.ASSISTANT_MESSAGE_ID, expect.objectContaining({ traceId }), ); streamSpy.mockRestore(); }); }); describe('internal_resendMessage', () => { it('should not resend when message does not exist', async () => { const { result } = renderHook(() => useChatStore()); const coreProcessSpy = vi.fn(); act(() => { setupStoreWithMessages([]); useChatStore.setState({ internal_coreProcessMessage: coreProcessSpy }); }); await act(async () => { await result.current.internal_resendMessage('non-existent-id'); }); expect(coreProcessSpy).not.toHaveBeenCalled(); expect(result.current.refreshMessages).not.toHaveBeenCalled(); }); describe('context generation', () => { it('should generate correct context for user role message', async () => { const { result } = renderHook(() => useChatStore()); const messages = [ createMockMessage({ id: 'msg-1', role: 'system' }), createMockMessage({ id: TEST_IDS.MESSAGE_ID, role: 'user', meta: { avatar: '😀' } }), createMockMessage({ id: 'msg-3', role: 'assistant' }), ]; act(() => { useChatStore.setState({ messagesMap: { [chatSelectors.currentChatKey(useChatStore.getState() as any)]: messages, }, }); }); await act(async () => { await result.current.internal_resendMessage(TEST_IDS.MESSAGE_ID); }); expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ id: 'msg-1' }), expect.objectContaining({ id: TEST_IDS.MESSAGE_ID }), ]), TEST_IDS.MESSAGE_ID, expect.objectContaining({ traceId: undefined }), ); }); it('should generate correct context for assistant role message', async () => { const { result } = renderHook(() => useChatStore()); const parentId = 'msg-2'; const messages = [ createMockMessage({ id: 'msg-1', role: 'system' }), createMockMessage({ id: parentId, role: 'user', meta: { avatar: '😀' } }), createMockMessage({ id: TEST_IDS.MESSAGE_ID, role: 'assistant', parentId }), ]; act(() => { useChatStore.setState({ messagesMap: { [chatSelectors.currentChatKey(useChatStore.getState() as any)]: messages, }, }); }); await act(async () => { await result.current.internal_resendMessage(TEST_IDS.MESSAGE_ID); }); expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ id: 'msg-1' }), expect.objectContaining({ id: parentId }), ]), parentId, expect.objectContaining({ traceId: undefined }), ); }); it('should not process when context is empty', async () => { const { result } = renderHook(() => useChatStore()); act(() => { useChatStore.setState({ messagesMap: { [chatSelectors.currentChatKey(useChatStore.getState() as any)]: [], }, }); }); await act(async () => { await result.current.internal_resendMessage(TEST_IDS.MESSAGE_ID); }); expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled(); }); it('should generate correct context for tool role message', async () => { const { result } = renderHook(() => useChatStore()); const messages = [ createMockMessage({ id: 'msg-1', role: 'user' }), createMockMessage({ id: 'msg-2', role: 'assistant' }), createMockMessage({ id: TEST_IDS.MESSAGE_ID, role: 'tool' }), ]; act(() => { useChatStore.setState({ messagesMap: { [chatSelectors.currentChatKey(useChatStore.getState() as any)]: messages, }, }); }); await act(async () => { await result.current.internal_resendMessage(TEST_IDS.MESSAGE_ID); }); // For tool role, it processes all messages up to tool but uses last user message as parentId expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith( expect.any(Array), 'msg-1', // parentId is the last user message expect.objectContaining({ traceId: undefined }), ); }); }); }); describe('internal_toggleChatLoading', () => { it('should enable loading state with new abort controller', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'test-action'); }); const state = useChatStore.getState(); expect(state.chatLoadingIdsAbortController).toBeInstanceOf(AbortController); expect(state.chatLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]); }); it('should disable loading state and clear abort controller', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'start'); result.current.internal_toggleChatLoading(false, undefined, 'stop'); }); const state = useChatStore.getState(); expect(state.chatLoadingIdsAbortController).toBeUndefined(); expect(state.chatLoadingIds).toEqual([]); }); it('should manage beforeunload event listener', () => { const { result } = renderHook(() => useChatStore()); const addListenerSpy = vi.spyOn(window, 'addEventListener'); const removeListenerSpy = vi.spyOn(window, 'removeEventListener'); act(() => { result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'start'); }); expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); act(() => { result.current.internal_toggleChatLoading(false, undefined, 'stop'); }); expect(removeListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); }); it('should reuse existing abort controller', () => { const existingController = new AbortController(); act(() => { useChatStore.setState({ chatLoadingIdsAbortController: existingController }); }); const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'test'); }); const state = useChatStore.getState(); expect(state.chatLoadingIdsAbortController).toStrictEqual(existingController); }); }); describe('internal_toggleToolCallingStreaming', () => { it('should track tool calling stream status', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleToolCallingStreaming(TEST_IDS.MESSAGE_ID, [true]); }); expect(result.current.toolCallingStreamIds[TEST_IDS.MESSAGE_ID]).toEqual([true]); }); it('should clear tool calling stream status', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleToolCallingStreaming(TEST_IDS.MESSAGE_ID, [true]); result.current.internal_toggleToolCallingStreaming(TEST_IDS.MESSAGE_ID, undefined); }); expect(result.current.toolCallingStreamIds[TEST_IDS.MESSAGE_ID]).toBeUndefined(); }); }); describe('internal_toggleSearchWorkflow', () => { it('should enable search workflow loading state', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleSearchWorkflow(true, TEST_IDS.MESSAGE_ID); }); const state = useChatStore.getState(); expect(state.searchWorkflowLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]); }); it('should disable search workflow loading state', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleSearchWorkflow(true, TEST_IDS.MESSAGE_ID); result.current.internal_toggleSearchWorkflow(false, TEST_IDS.MESSAGE_ID); }); const state = useChatStore.getState(); expect(state.searchWorkflowLoadingIds).toEqual([]); }); }); describe('internal_toggleChatReasoning', () => { it('should enable reasoning loading state', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleChatReasoning(true, TEST_IDS.MESSAGE_ID, 'test-action'); }); const state = useChatStore.getState(); expect(state.reasoningLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]); }); it('should disable reasoning loading state', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleChatReasoning(true, TEST_IDS.MESSAGE_ID, 'start'); result.current.internal_toggleChatReasoning(false, TEST_IDS.MESSAGE_ID, 'stop'); }); const state = useChatStore.getState(); expect(state.reasoningLoadingIds).toEqual([]); }); }); describe('internal_toggleMessageInToolsCalling', () => { it('should enable tools calling state', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleMessageInToolsCalling(true, TEST_IDS.MESSAGE_ID); }); const state = useChatStore.getState(); expect(state.messageInToolsCallingIds).toEqual([TEST_IDS.MESSAGE_ID]); }); it('should disable tools calling state', () => { const { result } = renderHook(() => useChatStore()); act(() => { result.current.internal_toggleMessageInToolsCalling(true, TEST_IDS.MESSAGE_ID); result.current.internal_toggleMessageInToolsCalling(false, TEST_IDS.MESSAGE_ID); }); const state = useChatStore.getState(); expect(state.messageInToolsCallingIds).toEqual([]); }); }); describe('internal_resendMessage with custom params', () => { it('should use provided messages instead of store messages', async () => { const { result } = renderHook(() => useChatStore()); const customMessages = [createMockMessage({ id: 'custom-msg', role: 'user' })]; await act(async () => { await result.current.internal_resendMessage('custom-msg', { messages: customMessages }); }); expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith( expect.arrayContaining([expect.objectContaining({ id: 'custom-msg' })]), 'custom-msg', expect.anything(), ); }); it('should handle assistant message without parentId', async () => { const { result } = renderHook(() => useChatStore()); const messages = [ createMockMessage({ id: 'msg-1', role: 'user' }), createMockMessage({ id: TEST_IDS.MESSAGE_ID, role: 'assistant', parentId: undefined }), ]; act(() => { useChatStore.setState({ messagesMap: { [chatSelectors.currentChatKey(useChatStore.getState() as any)]: messages, }, }); }); await act(async () => { await result.current.internal_resendMessage(TEST_IDS.MESSAGE_ID); }); // Should handle the case where parentId is not found expect(result.current.internal_coreProcessMessage).toHaveBeenCalled(); }); }); });