import React from 'react'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import ChatBox from './ChatBox'; import { I18nContext } from '../i18n/I18nProvider'; import type { ResolvedLocaleCode } from '../i18n/types'; import { translate } from '../i18n/translate'; import type { ChatMessage } from '../types/projectTypes'; const { agentCoreMock, processUserMessageMock, processStreamMock, streamProcessorCallbacks, clearChatHistoryAndUIMock, projectStoreState, conversationStorageMock, showConfirmMock, } = vi.hoisted(() => ({ agentCoreMock: { setLLMProvider: vi.fn(), getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })), abortCurrentRequest: vi.fn(), getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []), getFullMessages: vi.fn(() => []), getConversationId: vi.fn(() => 'conv_test'), getTodos: vi.fn(() => []), subscribeTodoChanges: vi.fn(() => () => undefined), })), restoreConversation: vi.fn(), compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })), shouldCompactBeforeNextTurn: vi.fn(async () => false), }, processUserMessageMock: vi.fn(), processStreamMock: vi.fn(async () => ''), clearChatHistoryAndUIMock: vi.fn(), projectStoreState: { projectName: 'Test Project', toolFastForwardEnabled: false, setStatus: vi.fn(), setToolFastForwardEnabled: vi.fn(), toggleToolFastForwardEnabled: vi.fn(), }, conversationStorageMock: { initialize: vi.fn(async () => undefined), saveConversation: vi.fn(async () => undefined), loadConversation: vi.fn(async () => null), listConversations: vi.fn(async () => []), deleteConversation: vi.fn(async () => undefined), }, showConfirmMock: vi.fn(async () => true), streamProcessorCallbacks: { onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined, onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined, onMessageRemove: undefined as ((messageId: string) => void) | undefined, onProcessingChange: undefined as ((isProcessing: boolean) => void) | undefined, }, })); projectStoreState.setToolFastForwardEnabled.mockImplementation((enabled: boolean) => { projectStoreState.toolFastForwardEnabled = enabled; }); projectStoreState.toggleToolFastForwardEnabled.mockImplementation(() => { projectStoreState.toolFastForwardEnabled = !projectStoreState.toolFastForwardEnabled; }); vi.mock('./chat', () => ({ UserMessage: ({ content }: { content: string }) =>
{content}
, AssistantMessage: ({ content, todoSnapshot, }: { content: string; todoSnapshot?: Array<{ text: string }>; }) => (
{todoSnapshot ? `TODO SNAPSHOT: ${todoSnapshot.map(todo => todo.text).join(', ')}` : content}
), })); vi.mock('../agent/core/AgentCore', () => ({ AgentCore: { instance: () => agentCoreMock, }, })); vi.mock('../agent/llm/LLMProvider', () => ({ OpenAICompatibleLLMProvider: vi.fn(), })); vi.mock('../agent/llm/LocalBrowserLLMProvider', () => ({ LocalBrowserLLMProvider: vi.fn(), })); vi.mock('../core/config/ConfigManager', () => ({ ConfigManager: { instance: () => ({ getIsInitialized: () => true, initialize: vi.fn().mockResolvedValue(undefined), get: (key: string) => { if (key === 'general.llm_provider') { return 'openai'; } return ''; }, addChangeListener: () => () => undefined, }), }, })); vi.mock('../stores/projectStore', () => ({ useProjectStore: Object.assign( ((selector?: (state: typeof projectStoreState) => unknown) => ( selector ? selector(projectStoreState) : projectStoreState )) as never, { getState: () => projectStoreState, } ), })); vi.mock('../agent/core/SystemPrompts', () => ({ SystemPrompts: { getSystemPromptWithContext: vi.fn(), }, })); vi.mock('../util/chatUtil', () => ({ clearChatHistoryAndUI: clearChatHistoryAndUIMock, registerClearChatUICallback: vi.fn(), })); vi.mock('../util/messageFilter/UserMessageFilter', () => ({ processUserMessage: processUserMessageMock, })); vi.mock('../hooks/useStreamProcessor', () => ({ useStreamProcessor: (options: typeof streamProcessorCallbacks) => { streamProcessorCallbacks.onMessageAdd = options.onMessageAdd; streamProcessorCallbacks.onMessageUpdate = options.onMessageUpdate; streamProcessorCallbacks.onMessageRemove = options.onMessageRemove; streamProcessorCallbacks.onProcessingChange = options.onProcessingChange; return { abortController: null, processStream: processStreamMock, }; }, })); vi.mock('../utils/chatMessageUtils', () => ({ createMessage: vi.fn((role: 'user' | 'assistant', content: string) => ({ id: `${role}-${content}`, role, content, })), createStreamingMessage: vi.fn(() => ({ id: 'streaming-message', role: 'assistant', content: 'Thinking... click here to abort.', isStreaming: true, tokenCount: 0, })), addWelcomeMessage: vi.fn().mockResolvedValue(null), })); vi.mock('../util/timeUtil', () => ({ formatLocalDateTime: vi.fn(), })); vi.mock('../util/miscUtil', () => ({ downloadBlob: vi.fn(), buildTimestampSuffix: vi.fn(), })); vi.mock('../util/localLLMModelManager', () => ({ LocalLLMModelManager: { getState: () => ({ runtimeSupport: { supported: true, reason: null }, isCached: false, isDownloading: false, isChecking: false, progressText: '', progressPercent: 0, error: null, }), subscribe: () => () => undefined, }, })); vi.mock('../util/localLLMConfig', () => ({ LOCAL_LLM_DISPLAY_NAME: 'Gemma 4 E4B', LOCAL_LLM_PROVIDER_KEY: 'local_browser', })); vi.mock('../core/io/KGConversationStorage', () => ({ KGConversationStorage: { getInstance: () => conversationStorageMock, }, })); vi.mock('../util/dialogUtil', async () => { const actual = await vi.importActual('../util/dialogUtil'); return { ...actual, showConfirm: showConfirmMock, }; }); vi.mock('./common/KGDropdown', () => ({ default: () => null, })); function renderWithLocale(resolvedLocale: ResolvedLocaleCode) { return render( undefined, t: (key, params) => translate(key, params, resolvedLocale), }} > , ); } describe('ChatBox', () => { beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); }); beforeEach(() => { processUserMessageMock.mockReset(); processStreamMock.mockClear(); clearChatHistoryAndUIMock.mockClear(); conversationStorageMock.initialize.mockClear(); conversationStorageMock.saveConversation.mockClear(); conversationStorageMock.loadConversation.mockClear(); conversationStorageMock.listConversations.mockClear(); conversationStorageMock.deleteConversation.mockClear(); showConfirmMock.mockClear(); showConfirmMock.mockResolvedValue(true); agentCoreMock.compactConversation.mockClear(); agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false); agentCoreMock.restoreConversation.mockClear(); streamProcessorCallbacks.onMessageAdd = undefined; streamProcessorCallbacks.onMessageUpdate = undefined; streamProcessorCallbacks.onMessageRemove = undefined; streamProcessorCallbacks.onProcessingChange = undefined; projectStoreState.projectName = 'Test Project'; projectStoreState.toolFastForwardEnabled = false; projectStoreState.setStatus.mockClear(); projectStoreState.setToolFastForwardEnabled.mockClear(); projectStoreState.toggleToolFastForwardEnabled.mockClear(); agentCoreMock.getAgentState.mockReturnValue({ getMessages: vi.fn(() => []), getFullMessages: vi.fn(() => []), getConversationId: vi.fn(() => 'conv_test'), getTodos: vi.fn(() => []), subscribeTodoChanges: vi.fn(() => () => undefined), }); }); it('renders the English assistant title under en_us', () => { renderWithLocale('en_us'); expect(screen.getByRole('heading', { level: 3, name: 'K.G.Studio Musician Assistant' })).toBeTruthy(); }); it('renders the Chinese assistant title under zh_cn', () => { renderWithLocale('zh_cn'); expect(screen.getByRole('heading', { level: 3, name: 'K.G.Studio 音乐创作助手' })).toBeTruthy(); }); it('renders the French assistant title under fr_fr', () => { renderWithLocale('fr_fr'); expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy(); }); it('shows compacting status and completion for /compact', async () => { processUserMessageMock.mockResolvedValue({ displayUserMessage: false, sendToLLM: false, finalMessageForLLM: null, pseudoAssistantResponse: null, metadata: { command: 'compact', focus: 'keep the latest work', }, }); renderWithLocale('en_us'); const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line'); fireEvent.change(input, { target: { value: '/compact keep the latest work' } }); fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); await waitFor(() => { expect(agentCoreMock.compactConversation).toHaveBeenCalled(); expect(screen.getByText('Conversation Compacted')).toBeTruthy(); }); }); it('does not render a pinned todo checklist from agent state', async () => { renderWithLocale('en_us'); await waitFor(() => { expect(screen.queryByText('Task Checklist')).toBeNull(); }); }); it('removes older incomplete todo snapshots before appending a new one', async () => { processUserMessageMock.mockResolvedValue({ displayUserMessage: false, sendToLLM: true, finalMessageForLLM: 'todo prompt', pseudoAssistantResponse: null, metadata: null, }); processStreamMock.mockImplementation(async () => { streamProcessorCallbacks.onMessageAdd?.({ id: 'todo-1', role: 'assistant', content: 'todo 1', toolName: 'update_todo_list', toolSuccess: true, todoSnapshot: [ { id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 }, { id: '2', text: 'Write harmony', status: 'in_progress', updatedAt: 2 }, ], }); streamProcessorCallbacks.onMessageAdd?.({ id: 'todo-2', role: 'assistant', content: 'todo 2', toolName: 'update_todo_list', toolSuccess: true, todoSnapshot: [ { id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 3 }, { id: '2', text: 'Write bass', status: 'pending', updatedAt: 4 }, ], }); return ''; }); renderWithLocale('en_us'); const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line'); fireEvent.change(input, { target: { value: 'todo cleanup' } }); fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); await waitFor(() => { expect(screen.queryByText('TODO SNAPSHOT: Inspect melody, Write harmony')).toBeNull(); expect(screen.getByText('TODO SNAPSHOT: Inspect melody, Write bass')).toBeTruthy(); }); }); it('preserves completed todo snapshots when a new incomplete snapshot is added', async () => { processUserMessageMock.mockResolvedValue({ displayUserMessage: false, sendToLLM: true, finalMessageForLLM: 'todo prompt', pseudoAssistantResponse: null, metadata: null, }); processStreamMock.mockImplementation(async () => { streamProcessorCallbacks.onMessageAdd?.({ id: 'todo-complete', role: 'assistant', content: 'done snapshot', toolName: 'update_todo_list', toolSuccess: true, todoSnapshot: [ { id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 }, { id: '2', text: 'Write harmony', status: 'completed', updatedAt: 2 }, ], }); streamProcessorCallbacks.onMessageAdd?.({ id: 'todo-active', role: 'assistant', content: 'active snapshot', toolName: 'update_todo_list', toolSuccess: true, todoSnapshot: [ { id: '1', text: 'Mix stems', status: 'completed', updatedAt: 3 }, { id: '2', text: 'Render bounce', status: 'in_progress', updatedAt: 4 }, ], }); return ''; }); renderWithLocale('en_us'); const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line'); fireEvent.change(input, { target: { value: 'todo preserve' } }); fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); await waitFor(() => { expect(screen.getByText('TODO SNAPSHOT: Inspect melody, Write harmony')).toBeTruthy(); expect(screen.getByText('TODO SNAPSHOT: Mix stems, Render bounce')).toBeTruthy(); }); }); it('does not remove non-todo assistant messages during todo cleanup', async () => { processUserMessageMock.mockResolvedValue({ displayUserMessage: false, sendToLLM: true, finalMessageForLLM: 'todo prompt', pseudoAssistantResponse: null, metadata: null, }); processStreamMock.mockImplementation(async () => { streamProcessorCallbacks.onMessageAdd?.({ id: 'assistant-note', role: 'assistant', content: 'Normal assistant message', }); streamProcessorCallbacks.onMessageAdd?.({ id: 'todo-1', role: 'assistant', content: 'todo 1', toolName: 'update_todo_list', toolSuccess: true, todoSnapshot: [ { id: '1', text: 'Inspect melody', status: 'pending', updatedAt: 1 }, ], }); streamProcessorCallbacks.onMessageAdd?.({ id: 'todo-2', role: 'assistant', content: 'todo 2', toolName: 'update_todo_list', toolSuccess: true, todoSnapshot: [ { id: '1', text: 'Render bounce', status: 'in_progress', updatedAt: 2 }, ], }); return ''; }); renderWithLocale('en_us'); const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line'); fireEvent.change(input, { target: { value: 'todo cleanup keep assistant' } }); fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); await waitFor(() => { expect(screen.getByText('Normal assistant message')).toBeTruthy(); expect(screen.queryByText('TODO SNAPSHOT: Inspect melody')).toBeNull(); expect(screen.getByText('TODO SNAPSHOT: Render bounce')).toBeTruthy(); }); }); it('renders and toggles the fast-forward button state', () => { const { rerender } = renderWithLocale('en_us'); const button = screen.getByTitle('Fast forward tool execution approvals'); expect(button).toHaveAttribute('aria-pressed', 'false'); fireEvent.click(button); rerender( undefined, t: (key, params) => translate(key, params, 'en_us'), }} > , ); expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'true'); }); it('resets fast-forward through the shared new chat clear path', async () => { projectStoreState.toolFastForwardEnabled = true; clearChatHistoryAndUIMock.mockImplementation(() => { projectStoreState.setToolFastForwardEnabled(false); }); const { rerender } = renderWithLocale('en_us'); fireEvent.click(screen.getByTitle('New Chat')); rerender( undefined, t: (key, params) => translate(key, params, 'en_us'), }} > , ); await waitFor(() => { expect(clearChatHistoryAndUIMock).toHaveBeenCalled(); }); rerender( undefined, t: (key, params) => translate(key, params, 'en_us'), }} > , ); expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'false'); }); it('autosaves a completed conversation after sending', async () => { agentCoreMock.getAgentState.mockReturnValue({ getMessages: vi.fn(() => [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }]), getFullMessages: vi.fn(() => [ { id: 'm1', role: 'user', content: 'hello', timestamp: 1 }, { id: 'm2', role: 'assistant', content: 'world', timestamp: 2 }, ]), getConversationId: vi.fn(() => 'conv_saved'), getTodos: vi.fn(() => []), subscribeTodoChanges: vi.fn(() => () => undefined), }); processUserMessageMock.mockResolvedValue({ displayUserMessage: true, sendToLLM: true, finalMessageForLLM: 'hello', pseudoAssistantResponse: null, metadata: null, }); processStreamMock.mockImplementation(async () => { streamProcessorCallbacks.onMessageAdd?.({ id: 'assistant-1', role: 'assistant', content: 'world' }); return 'world'; }); renderWithLocale('en_us'); const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line'); fireEvent.change(input, { target: { value: 'hello' } }); fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); await waitFor(() => { expect(conversationStorageMock.saveConversation).toHaveBeenCalledTimes(1); }); const persistedDocument = ((conversationStorageMock.saveConversation.mock.calls[0] as unknown) as [string, { displayTranscript: ChatMessage[] }])[1]; expect(persistedDocument.displayTranscript).toEqual([ expect.objectContaining({ role: 'user', content: 'hello' }), expect.objectContaining({ role: 'assistant', content: 'world' }), ]); }); it('loads and restores a selected saved conversation from history', async () => { conversationStorageMock.listConversations.mockResolvedValue([ { conversationId: 'conv_old', title: 'Earlier conversation', createdAt: 1, updatedAt: 2, lastTurnAt: 2, messageCount: 2, preview: 'Preview', }, ] as never); conversationStorageMock.loadConversation.mockResolvedValue({ meta: { conversationId: 'conv_old', title: 'Earlier conversation', createdAt: 1, updatedAt: 2, lastTurnAt: 2, messageCount: 2, preview: 'Preview', }, document: { version: 1, conversationId: 'conv_old', continuationState: { messages: [{ id: 'a', role: 'user', content: 'prompt', timestamp: 1 }], todos: [], }, fullHistory: { messages: [ { id: 'a', role: 'user', content: 'prompt', timestamp: 1 }, { id: 'b', role: 'assistant', content: 'reply', timestamp: 2 }, ], }, displayTranscript: [ { id: 'display-a', role: 'user', content: 'prompt' }, { id: 'display-b', role: 'assistant', content: 'reply' }, ], }, } as never); renderWithLocale('en_us'); fireEvent.click(screen.getByTitle('Conversation history')); await waitFor(() => { expect(screen.getByText('Earlier conversation')).toBeTruthy(); }); fireEvent.click(screen.getByText('Earlier conversation')); await waitFor(() => { expect(agentCoreMock.restoreConversation).toHaveBeenCalledTimes(1); expect(screen.getByText('prompt')).toBeTruthy(); expect(screen.getByText('reply')).toBeTruthy(); }); }); it('deletes a saved conversation after confirmation', async () => { conversationStorageMock.listConversations.mockResolvedValue([ { conversationId: 'conv_old', title: 'Earlier conversation', createdAt: 1, updatedAt: 2, lastTurnAt: 2, messageCount: 2, preview: 'Preview', }, ] as never); renderWithLocale('en_us'); fireEvent.click(screen.getByTitle('Conversation history')); await waitFor(() => { expect(screen.getByText('Earlier conversation')).toBeTruthy(); }); fireEvent.click(screen.getByLabelText('Delete')); await waitFor(() => { expect(showConfirmMock).toHaveBeenCalledTimes(1); expect(conversationStorageMock.deleteConversation).toHaveBeenCalledWith('Test Project', 'conv_old'); expect(screen.queryByText('Earlier conversation')).toBeNull(); }); }); });