// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Comprehensive unit tests for ChatBox component import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BrowserRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fetchDelete, fetchGet, fetchPost, fetchPut, proxyFetchDelete, proxyFetchGet, } from '../../../src/api/http'; import ChatBox from '../../../src/components/ChatBox/index'; import { useAuthStore } from '../../../src/store/authStore'; const eventNativeHarness = vi.hoisted(() => ({ enabled: false, snapshot: null as any, controlOptions: null as any, })); // Mock dependencies (use the same relative paths as the imports above) vi.mock('../../../src/store/authStore', () => ({ useAuthStore: vi.fn(), getAuthStore: vi.fn(() => ({ language: 'en-US', setLanguage: vi.fn() })), })); vi.mock('../../../src/api/http', () => ({ fetchGet: vi.fn(), fetchPost: vi.fn(), fetchPut: vi.fn(), fetchDelete: vi.fn(), proxyFetchGet: vi.fn(), proxyFetchDelete: vi.fn(), })); // Also mock the alias paths the component uses so the component picks up these mocks vi.mock('@/store/authStore', () => ({ useAuthStore: vi.fn(), getAuthStore: vi.fn(() => ({ language: 'en-US', setLanguage: vi.fn() })), })); vi.mock('@/api/http', () => ({ fetchGet: vi.fn(), fetchPost: vi.fn(), fetchPut: vi.fn(), fetchDelete: vi.fn(), proxyFetchGet: vi.fn(), proxyFetchDelete: vi.fn(), })); vi.mock('@/store/chatEventProjectionBridge', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isChatEventTimelineEnabled: () => eventNativeHarness.enabled, }; }); vi.mock('@/hooks/useProjectEventRuntime', () => ({ useProjectEventRuntime: () => ({ hydration: { status: 'ready', errorCode: null, eventsTruncated: false, retry: vi.fn(), }, projectId: 'test-project-id', snapshot: eventNativeHarness.snapshot, }), })); vi.mock('../../../src/components/ChatBox/EventNativeProjectTimeline', () => ({ EventNativeProjectTimeline: ({ floatingControl }: any) => (
{floatingControl}
), })); vi.mock( '../../../src/components/ChatBox/BottomBox/useEventNativeHumanControl', () => ({ useEventNativeHumanControl: (options: any) => { eventNativeHarness.controlOptions = options; return { interaction: null, variant: null, pendingCount: 0, phase: 'idle', submitError: null, }; }, }) ); vi.mock('../../../src/lib', () => ({ generateUniqueId: vi.fn(() => 'test-unique-id'), replayActiveTask: vi.fn(), })); // Mock projectStore with proper vanilla store structure vi.mock('../../../src/store/projectStore', () => { const useProjectStore = vi.fn(); (useProjectStore as any).getState = vi.fn(() => ({ getAllChatStores: () => [], })); return { useProjectStore }; }); vi.mock('@/store/projectStore', () => { const useProjectStore = vi.fn(); (useProjectStore as any).getState = vi.fn(() => ({ getAllChatStores: () => [], })); return { useProjectStore }; }); // Mock useChatStoreAdapter to provide both stores vi.mock('../../../src/hooks/useChatStoreAdapter', () => ({ default: vi.fn(), })); vi.mock('@/hooks/useModelConfigCheck', () => ({ useModelConfigCheck: () => ({ hasModel: true, isConfigLoaded: true, cloudUsageLimitReached: false, }), })); // Mock i18next for translations vi.mock('react-i18next', () => ({ initReactI18next: { type: '3rdParty', init: () => {}, }, useTranslation: () => ({ t: (key: string) => { const translations: Record = { 'chat.ask-placeholder': 'Type your message...', 'chat.attachment-only-message': 'Please use the attached file(s).', 'layout.by-messaging-eigent': 'By messaging Eigent, you agree to our', 'layout.terms-of-use': 'Terms of Use', 'layout.and': 'and', 'layout.privacy-policy': 'Privacy Policy', }; return translations[key] || key; }, }), })); // Mock BottomBox component vi.mock('../../../src/components/ChatBox/BottomBox', () => ({ default: vi.fn(({ inputProps }: any) => { if (!inputProps) return null; const hasContent = (inputProps.value || '').trim().length > 0 || inputProps.files?.length > 0; const primaryAction = hasContent ? 'send' : inputProps.taskControlState === 'running' ? 'pause' : inputProps.taskControlState === 'paused' ? 'resume' : 'idle'; return (
inputProps.onChange(e.target.value)} />
); }), })); // Mock ProjectChatContainer to avoid scrollTo issues vi.mock('../../../src/components/ChatBox/ProjectChatContainer', () => ({ ProjectChatContainer: vi.fn(() => (
Chat Container
)), })); // Mock other components vi.mock('../../../src/components/ChatBox/MessageCard', () => ({ MessageCard: vi.fn(({ content, role }: any) => (
{content}
)), })); vi.mock('../../../src/components/ChatBox/TaskCard', () => ({ TaskCard: vi.fn(() =>
Task Card
), })); vi.mock('../../../src/components/ChatBox/NoticeCard', () => ({ NoticeCard: vi.fn(() =>
Notice Card
), })); vi.mock('../../../src/components/ChatBox/TypeCardSkeleton', () => ({ TypeCardSkeleton: vi.fn(() =>
Loading...
), })); describe('ChatBox Component', async () => { const mockUseAuthStore = vi.mocked(useAuthStore); const mockFetchGet = vi.mocked(fetchGet); const _mockFetchPost = vi.mocked(fetchPost); const _mockFetchPut = vi.mocked(fetchPut); const _mockFetchDelete = vi.mocked(fetchDelete); const mockProxyFetchGet = vi.mocked(proxyFetchGet); const _mockProxyFetchDelete = vi.mocked(proxyFetchDelete); // Import the mocked hook const mockUseChatStoreAdapter = vi.mocked( (await import('../../../src/hooks/useChatStoreAdapter')).default ); const mockUseProjectStore = vi.mocked( (await import('../../../src/store/projectStore')).useProjectStore ); const defaultChatStoreState = { activeTaskId: 'test-task-id', tasks: { 'test-task-id': { messages: [], hasMessages: false, isPending: false, activeAsk: '', askList: [], hasWaitComfirm: false, isTakeControl: false, type: 'normal', delayTime: 0, status: 'pending', taskInfo: [], attaches: [], taskRunning: [], taskAssigning: [], cotList: [], activeWorkspace: null, snapshots: [], isTaskEdit: false, isContextExceeded: false, }, }, setHasMessages: vi.fn(), addMessages: vi.fn(), removeMessage: vi.fn(), setIsPending: vi.fn(), startTask: vi.fn(), setActiveAsk: vi.fn(), setActiveAskList: vi.fn(), setHasWaitComfirm: vi.fn(), handleConfirmTask: vi.fn(), setActiveTaskId: vi.fn(), create: vi.fn(), setSelectedFile: vi.fn(), setActiveWorkspace: vi.fn(), setIsTakeControl: vi.fn(), setIsTaskEdit: vi.fn(), addTaskInfo: vi.fn(), updateTaskInfo: vi.fn(), saveTaskInfo: vi.fn(), deleteTaskInfo: vi.fn(), getFormattedTaskTime: vi.fn(() => '00:00:00'), setAttaches: vi.fn(), setNextTaskId: vi.fn(), setNextExecutionId: vi.fn(), setTaskSessionMode: vi.fn(), setTaskSource: vi.fn(), setExecutionId: vi.fn(), removeTask: vi.fn(), stopTask: vi.fn(), setElapsed: vi.fn(), setTaskTime: vi.fn(), setStatus: vi.fn(), setDurableRunStatus: vi.fn(), markHumanInteractionResolved: vi.fn(), }; const preparedRunState = { setNextTaskId: vi.fn(), setTaskSessionMode: vi.fn(), setTaskSource: vi.fn(), setExecutionId: vi.fn(), setIsPending: vi.fn(), setHasMessages: vi.fn(), addMessages: vi.fn(), setStatus: vi.fn(), }; const preparedRunChatStore = { getState: vi.fn(() => preparedRunState), }; const defaultProjectStoreState = { activeProjectId: 'test-project-id', projects: {}, createProject: vi.fn(), setActiveProject: vi.fn(), removeProject: vi.fn(), updateProject: vi.fn(), replayProject: vi.fn(), addQueuedMessage: vi.fn(), removeQueuedMessage: vi.fn(), restoreQueuedMessage: vi.fn(), clearQueuedMessages: vi.fn(), setQueuedMessageProcessing: vi.fn(), createChatStore: vi.fn(), appendInitChatStore: vi.fn((_projectId: string, _taskId: string) => ({ chatStore: preparedRunChatStore, })), setActiveChatStore: vi.fn(), removeChatStore: vi.fn(), saveChatStore: vi.fn(), getChatStore: vi.fn(), getActiveChatStore: vi.fn(() => ({ getState: () => defaultChatStoreState, subscribe: () => () => {}, })), getAllChatStores: vi.fn(() => []), getAllProjects: vi.fn(), getProjectById: vi.fn(() => ({ queuedMessages: [] })), getProjectTotalTokens: vi.fn(), setHistoryId: vi.fn(), getHistoryId: vi.fn(), }; const defaultAuthStoreState = { modelType: 'cloud', }; const runningEventNativeSnapshot = (runId = 'test-task-id') => ({ view: { projectId: 'test-project-id', mode: 'live', seenEventIds: {}, currentCursor: 1, eventsTruncated: false, lastSyncedAt: null, needsResync: false, resyncReason: null, resyncTargetCursor: null, runs: { [runId]: { runId, status: 'running', lastSequence: 1, runVersion: 1, updatedAt: '2026-08-20T00:00:00Z', origin: 'local', resumeBlockedReason: null, }, }, artifactsByRun: {}, legacySteps: [], unknownEvents: [], }, chat: { projectId: 'test-project-id', nodes: [ { id: `${runId}:started`, eventId: `${runId}:started`, projectId: 'test-project-id', runId, createdAt: '2026-08-20T00:00:00Z', runSequence: 1, cloudCursor: 1, eventType: 'run.attempt_started', legacyStep: null, kind: 'run_status', status: 'running', }, ], nodeById: {}, seenEventIds: {}, }, control: { projectId: 'test-project-id', orderedInteractionIds: [], interactionById: {}, seenEventIds: {}, }, revision: 1, hasHydratedSnapshot: true, overflowed: false, lastEffects: [], }); beforeEach(() => { // Reset all mocks vi.clearAllMocks(); window.sessionStorage.clear(); eventNativeHarness.enabled = false; eventNativeHarness.snapshot = null; eventNativeHarness.controlOptions = null; defaultProjectStoreState.activeProjectId = 'test-project-id'; defaultProjectStoreState.getActiveChatStore.mockImplementation(() => ({ getState: () => defaultChatStoreState, subscribe: () => () => {}, })); defaultProjectStoreState.getProjectById.mockImplementation(() => ({ queuedMessages: [], })); defaultProjectStoreState.removeQueuedMessage.mockImplementation( () => undefined ); defaultProjectStoreState.getAllChatStores.mockReturnValue([]); // Setup default store states mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: defaultChatStoreState as any, }); mockUseProjectStore.mockReturnValue(defaultProjectStoreState as any); mockUseAuthStore.mockReturnValue(defaultAuthStoreState as any); // Setup default API responses mockFetchGet.mockResolvedValue({ runs: [] }); mockProxyFetchGet.mockImplementation((url: string) => { if (url === '/api/user/key' || url === '/api/v1/user/key') { return Promise.resolve({ value: 'test-api-key' }); } if (url === '/api/v1/configs') { return Promise.resolve([ { config_name: 'GOOGLE_API_KEY', value: 'test-key' }, { config_name: 'SEARCH_ENGINE_ID', value: 'test-id' }, ]); } return Promise.resolve({}); }); _mockFetchPost.mockResolvedValue({ success: true }); _mockFetchPut.mockResolvedValue({ success: true }); // Mock import.meta.env Object.defineProperty(import.meta, 'env', { value: { VITE_USE_LOCAL_PROXY: 'false' }, writable: true, }); }); afterEach(() => { vi.clearAllMocks(); }); const renderChatBox = () => { return render( ); }; describe('Initial Render', () => { it('should render bottom box when no messages exist', () => { renderChatBox(); expect(screen.getByTestId('bottom-box')).toBeInTheDocument(); }); it('should render message input in bottom box', () => { renderChatBox(); expect(screen.getByTestId('message-input')).toBeInTheDocument(); }); it('should not fetch privacy settings on mount', async () => { renderChatBox(); await waitFor(() => { expect(mockProxyFetchGet).not.toHaveBeenCalledWith('/api/user/privacy'); }); }); it('should fetch API configurations on mount', async () => { renderChatBox(); await waitFor(() => { expect(mockProxyFetchGet).toHaveBeenCalledWith('/api/v1/configs'); }); }); }); describe('Privacy', () => { it('should not fetch privacy settings on mount', async () => { renderChatBox(); // Privacy is now handled at login, not in ChatBox await waitFor(() => { expect(mockProxyFetchGet).not.toHaveBeenCalledWith('/api/user/privacy'); }); }); }); describe('Chat Interface', () => { beforeEach(() => { const updatedChatState = { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: '1', role: 'user', content: 'Hello', attaches: [], }, { id: '2', role: 'assistant', content: 'Hi there!', attaches: [], }, ], hasMessages: true, }, }, }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: updatedChatState as any, }); }); it('should render project chat container when messages exist', () => { renderChatBox(); expect(screen.getByTestId('project-chat-container')).toBeInTheDocument(); }); it.each([ ['running', 'pause'], ['pause', 'resume'], ] as const)( 'routes the empty composer %s state through the %s task control', async (status, action) => { const user = userEvent.setup(); const controlledTask = { ...defaultChatStoreState.tasks['test-task-id'], status, hasMessages: true, messages: [{ id: '1', role: 'user', content: 'Start', attaches: [] }], elapsed: 100, taskTime: 1_000, }; const controlledStore = { ...defaultChatStoreState, tasks: { 'test-task-id': controlledTask }, setElapsed: vi.fn(), setTaskTime: vi.fn(), setStatus: vi.fn(), }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: controlledStore as any, }); renderChatBox(); const actionButton = screen.getByTestId('send-button'); expect(actionButton).toHaveAttribute( 'data-composer-primary-action', action ); await user.click(actionButton); await waitFor(() => { expect(_mockFetchPut).toHaveBeenCalledWith( '/task/test-project-id/take-control', { action } ); }); } ); it('sends an attachment-only draft with a usable instruction', async () => { const user = userEvent.setup(); const attachment = { fileName: 'brief.pdf', filePath: '/tmp/brief.pdf', }; const attachmentStore = { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], attaches: [attachment], }, }, }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: attachmentStore as any, }); renderChatBox(); const actionButton = screen.getByTestId('send-button'); expect(actionButton).toHaveAttribute( 'data-composer-primary-action', 'send' ); await user.click(actionButton); await waitFor(() => { expect(attachmentStore.startTask).toHaveBeenCalledWith( 'test-task-id', undefined, undefined, undefined, 'Please use the attached file(s).', [attachment], undefined, 'test-project-id', 'single-agent' ); }); }); it('should handle message sending', async () => { const user = userEvent.setup(); // Create a proper pending state where we can continue a conversation const updatedChatState = { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: '1', role: 'user', content: 'Hello', attaches: [], }, { id: '2', role: 'assistant', content: 'Hi there!', step: 'wait_confirm', // Add wait_confirm to allow continuation attaches: [], }, ], hasMessages: true, hasWaitComfirm: true, // Set hasWaitComfirm to true status: 'pending', // Keep it pending }, }, }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: updatedChatState as any, }); renderChatBox(); const messageInput = screen.getByTestId('message-input'); const sendButton = screen.getByTestId('send-button'); await user.type(messageInput, 'Test message'); await user.click(sendButton); // A follow-up is prepared as a new durable Run before admission. await waitFor(() => { expect(defaultProjectStoreState.appendInitChatStore).toHaveBeenCalled(); expect(_mockFetchPost).toHaveBeenCalledWith( '/chat/test-project-id', expect.objectContaining({ question: 'Test message', }) ); }); const nextTaskId = defaultProjectStoreState.appendInitChatStore.mock.calls[0][1]; expect(preparedRunState.setNextTaskId).toHaveBeenCalledWith(nextTaskId); expect(preparedRunState.setTaskSessionMode).toHaveBeenCalledWith( nextTaskId, 'single-agent' ); expect(preparedRunState.setIsPending).toHaveBeenCalledWith( nextTaskId, true ); expect(preparedRunState.setHasMessages).toHaveBeenCalledWith( nextTaskId, true ); expect(_mockFetchPost).toHaveBeenCalledWith( '/chat/test-project-id', expect.objectContaining({ task_id: nextTaskId }) ); }); it('keeps the composer available and queues a second task while running', async () => { const user = userEvent.setup(); const runningChatState = { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: 'running-query', role: 'user', content: 'First task', attaches: [], }, ], hasMessages: true, status: 'running', }, }, }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: runningChatState as any, }); _mockFetchPost.mockResolvedValueOnce({ request_id: 'test-unique-id', project_id: 'test-project-id', content: 'Second task', attachment_paths: [], delivery_mode: 'wait', status: 'pending', source: 'local', created_at: 1, updated_at: 1, }); renderChatBox(); await user.type(screen.getByTestId('message-input'), 'Second task'); await user.click(screen.getByTestId('send-button')); await waitFor(() => { expect(_mockFetchPost).toHaveBeenCalledWith( '/projects/test-project-id/follow-ups', { request_id: 'test-unique-id', content: 'Second task', attachment_paths: [], delivery_mode: 'wait', source: 'local', source_command_id: undefined, } ); expect( defaultProjectStoreState.restoreQueuedMessage ).toHaveBeenCalledWith( 'test-project-id', expect.objectContaining({ task_id: 'test-unique-id', content: 'Second task', source: 'local', }) ); }); }); it('admits queued follow-ups one at a time without writing into the completed Run', async () => { const queuedMessages = [ { task_id: 'queued-run-1', run_id: 'queued-run-1', content: 'First queued follow-up', timestamp: 1, attaches: [], }, { task_id: 'queued-run-2', run_id: 'queued-run-2', content: 'Second queued follow-up', timestamp: 2, attaches: [], }, ]; defaultProjectStoreState.getProjectById.mockImplementation(() => ({ queuedMessages, })); defaultProjectStoreState.removeQueuedMessage.mockImplementation( (_projectId: string, taskId: string) => { const index = queuedMessages.findIndex( (item) => item.task_id === taskId ); return index >= 0 ? queuedMessages.splice(index, 1)[0] : undefined; } ); const completedTask = { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: 'completed-query', role: 'user', content: 'Completed task', attaches: [], }, ], hasMessages: true, hasWaitComfirm: true, status: 'finished', }; const completedStore = { ...defaultChatStoreState, tasks: { 'test-task-id': completedTask }, }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: completedStore as any, }); defaultProjectStoreState.getAllChatStores.mockReturnValue([]); eventNativeHarness.enabled = true; eventNativeHarness.snapshot = runningEventNativeSnapshot('test-task-id'); eventNativeHarness.snapshot.view.runs['test-task-id'].status = 'completed'; mockFetchGet.mockImplementation((url: string) => { if (url === '/chat/test-project-id/status') { return Promise.resolve({ has_lock: true, status: 'done', run_id: 'test-task-id', }); } return Promise.resolve({ items: [] }); }); const view = renderChatBox(); await waitFor(() => { expect(_mockFetchPost).toHaveBeenCalledWith('/chat/test-project-id', { question: 'First queued follow-up', task_id: 'queued-run-1', attaches: [], target: undefined, }); }); expect( _mockFetchPost.mock.calls.filter( ([url]) => url === '/chat/test-project-id' ) ).toHaveLength(1); expect(completedStore.addMessages).not.toHaveBeenCalled(); expect(defaultProjectStoreState.removeQueuedMessage).toHaveBeenCalledWith( 'test-project-id', 'queued-run-1' ); eventNativeHarness.snapshot = runningEventNativeSnapshot('queued-run-1'); eventNativeHarness.snapshot.revision = 2; view.rerender( ); await Promise.resolve(); expect( _mockFetchPost.mock.calls.filter( ([url]) => url === '/chat/test-project-id' ) ).toHaveLength(1); eventNativeHarness.snapshot = runningEventNativeSnapshot('queued-run-1'); eventNativeHarness.snapshot.revision = 3; eventNativeHarness.snapshot.view.runs['queued-run-1'].status = 'completed'; view.rerender( ); await waitFor(() => { expect(_mockFetchPost).toHaveBeenCalledWith('/chat/test-project-id', { question: 'Second queued follow-up', task_id: 'queued-run-2', attaches: [], target: undefined, }); }); }); it('should not send empty messages', async () => { const user = userEvent.setup(); renderChatBox(); const sendButton = screen.getByTestId('send-button'); await user.click(sendButton); expect(defaultChatStoreState.addMessages).not.toHaveBeenCalled(); }); }); describe('Event-native floating Stop', () => { const setRunningEventNativeStore = () => { const runningTask = { ...defaultChatStoreState.tasks['test-task-id'], status: 'running', hasMessages: true, messages: [{ id: '1', role: 'user', content: 'Start', attaches: [] }], }; const runningStore = { ...defaultChatStoreState, tasks: { 'test-task-id': runningTask }, stopTask: vi.fn(), setIsPending: vi.fn(), }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: runningStore as any, }); eventNativeHarness.enabled = true; eventNativeHarness.snapshot = runningEventNativeSnapshot(); return runningStore; }; it('keeps the plan overlay host mounted for the durable timeline', () => { setRunningEventNativeStore(); renderChatBox(); expect( document.getElementById('plan-task-overlay-root') ).toBeInTheDocument(); }); it('fails closed when the rendered Run loses control ownership before click', async () => { const user = userEvent.setup(); setRunningEventNativeStore(); renderChatBox(); const stopButton = await screen.findByRole('button', { name: 'Stop Task', }); const snapshot = eventNativeHarness.snapshot; snapshot.view.runs['typed-run'] = { ...snapshot.view.runs['test-task-id'], runId: 'typed-run', }; snapshot.control.orderedInteractionIds.push('typed-request'); snapshot.control.interactionById['typed-request'] = { interactionId: 'typed-request', runId: 'typed-run', status: 'requested', requestSource: 'canonical', requestEventType: 'interaction.requested', }; await user.click(stopButton); expect(_mockFetchPost).not.toHaveBeenCalledWith( expect.stringMatching(/^\/runs\//), expect.anything() ); }); it('reuses the request id after failure and never closes the legacy SSE', async () => { const user = userEvent.setup(); const runningStore = setRunningEventNativeStore(); const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); _mockFetchPost.mockRejectedValue(new Error('offline')); renderChatBox(); const stopButton = await screen.findByRole('button', { name: 'Stop Task', }); await user.click(stopButton); await waitFor(() => expect(_mockFetchPost).toHaveBeenCalledTimes(1)); await waitFor(() => expect(stopButton).not.toBeDisabled()); await user.click(stopButton); await waitFor(() => expect(_mockFetchPost).toHaveBeenCalledTimes(2)); const [firstUrl, firstBody] = _mockFetchPost.mock.calls[0]; const [secondUrl, secondBody] = _mockFetchPost.mock.calls[1]; expect(firstUrl).toBe('/runs/test-task-id/cancel'); expect(secondUrl).toBe(firstUrl); expect(secondBody.request_id).toBe(firstBody.request_id); expect(runningStore.stopTask).not.toHaveBeenCalled(); expect(runningStore.setIsPending).not.toHaveBeenCalled(); consoleError.mockRestore(); }); }); describe('Event-native human-control compatibility bridge', () => { it('reconciles the initiating Project after the user switches Projects', () => { eventNativeHarness.enabled = true; eventNativeHarness.snapshot = runningEventNativeSnapshot(); const projectAState = { ...defaultChatStoreState, activeTaskId: 'test-task-id', tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [], askList: [], }, }, setIsPending: vi.fn(), setDurableRunStatus: vi.fn(), setStatus: vi.fn(), markHumanInteractionResolved: vi.fn(), setActiveAskList: vi.fn(), setActiveAsk: vi.fn(), addMessages: vi.fn(), }; const projectBState = { ...projectAState, setIsPending: vi.fn(), setDurableRunStatus: vi.fn(), setStatus: vi.fn(), markHumanInteractionResolved: vi.fn(), setActiveAskList: vi.fn(), setActiveAsk: vi.fn(), addMessages: vi.fn(), }; const projectAStore = { getState: () => projectAState }; const projectBStore = { getState: () => projectBState }; defaultProjectStoreState.getActiveChatStore.mockImplementation( (projectId?: string) => projectId === 'test-project-id' ? projectAStore : projectBStore ); mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: projectAState as any, }); renderChatBox(); const initiatingCallbacks = eventNativeHarness.controlOptions; defaultProjectStoreState.activeProjectId = 'project-b'; const interaction = { interactionId: 'interaction-1', runId: 'test-task-id', }; initiatingCallbacks.onSubmissionStart(interaction); initiatingCallbacks.onSubmissionFailure(interaction); initiatingCallbacks.onDurableResolution(interaction); expect( defaultProjectStoreState.getActiveChatStore ).toHaveBeenLastCalledWith('test-project-id'); expect(projectAState.setIsPending).toHaveBeenCalledWith( 'test-task-id', true ); expect(projectAState.setIsPending).toHaveBeenCalledWith( 'test-task-id', false ); expect(projectAState.markHumanInteractionResolved).toHaveBeenCalledWith( 'test-task-id', 'interaction-1' ); expect(projectBState.setIsPending).not.toHaveBeenCalled(); expect(projectBState.markHumanInteractionResolved).not.toHaveBeenCalled(); }); }); describe('Task Management', () => { it('should render project chat container when tasks have messages', () => { mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: '1', role: 'assistant', content: '', step: 'to_sub_tasks', taskType: 1, }, ], hasMessages: true, isTakeControl: false, cotList: [], }, }, } as any, }); renderChatBox(); // With the new architecture, task cards are rendered inside ProjectChatContainer expect(screen.getByTestId('project-chat-container')).toBeInTheDocument(); }); it('should render project chat container for notice card scenario', () => { mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: '1', role: 'assistant', content: '', step: 'notice_card', }, ], hasMessages: true, isTakeControl: false, cotList: ['item1'], }, }, } as any, }); renderChatBox(); // With the new architecture, notice cards are rendered inside ProjectChatContainer expect(screen.getByTestId('project-chat-container')).toBeInTheDocument(); }); }); describe('Loading States', () => { it('should render project chat container when task is pending', () => { mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: '1', role: 'user', content: 'Hello', }, ], hasMessages: true, hasWaitComfirm: false, isTakeControl: false, }, }, } as any, }); renderChatBox(); // With the new architecture, loading states are handled inside ProjectChatContainer expect(screen.getByTestId('project-chat-container')).toBeInTheDocument(); }); }); describe('File Handling', () => { it('should render project chat container when message has files', () => { mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: '1', role: 'assistant', content: 'Task complete', step: 'end', fileList: [ { name: 'test-file.pdf', type: 'PDF', path: '/path/to/file', }, ], }, ], hasMessages: true, }, }, } as any, }); renderChatBox(); // With the new architecture, file lists are rendered inside ProjectChatContainer expect(screen.getByTestId('project-chat-container')).toBeInTheDocument(); }); it('should render project chat container for file handling', () => { mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], messages: [ { id: '1', role: 'assistant', content: 'Task complete', step: 'end', fileList: [ { name: 'test-file.pdf', type: 'PDF', path: '/path/to/file', }, ], }, ], hasMessages: true, }, }, } as any, }); renderChatBox(); // With the new architecture, file lists are rendered inside ProjectChatContainer expect(screen.getByTestId('project-chat-container')).toBeInTheDocument(); }); }); describe('Agent Interaction', () => { it('should handle human reply when activeAsk is set', async () => { const user = userEvent.setup(); mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], activeAsk: 'test-agent', askList: [], hasMessages: true, }, }, } as any, }); renderChatBox(); const messageInput = screen.getByTestId('message-input'); const sendButton = screen.getByTestId('send-button'); await user.type(messageInput, 'Test reply'); await user.click(sendButton); await waitFor(() => { // The API call now uses project ID instead of task ID expect(_mockFetchPost).toHaveBeenCalledWith( '/chat/test-project-id/human-reply', { agent: 'test-agent', reply: 'Test reply', } ); }); }); it('should clear stale human reply state when backend no longer has the task lock', async () => { const user = userEvent.setup(); _mockFetchPost.mockResolvedValueOnce({ code: 1, text: 'This task is no longer waiting for a human reply.', }); const storeObj = { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], activeAsk: 'test-agent', askList: [], hasMessages: true, }, }, } as any; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: storeObj, }); renderChatBox(); const messageInput = screen.getByTestId('message-input'); const sendButton = screen.getByTestId('send-button'); await user.type(messageInput, 'Late reply'); await user.click(sendButton); await waitFor(() => { expect(storeObj.removeMessage).toHaveBeenCalledWith( 'test-task-id', expect.any(String) ); expect(storeObj.setIsPending).toHaveBeenCalledWith( 'test-task-id', false ); expect(storeObj.setActiveAskList).toHaveBeenCalledWith( 'test-task-id', [] ); expect(storeObj.setActiveAsk).toHaveBeenCalledWith('test-task-id', ''); }); }); it('should process ask list when human reply is sent', async () => { const user = userEvent.setup(); const mockMessage = { id: '2', role: 'assistant', content: 'Next question', agent_name: 'next-agent', }; // Create a store object we can assert against so we capture the exact mocked functions const storeObj = { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], activeAsk: 'test-agent', askList: [mockMessage], hasMessages: true, }, }, } as any; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: storeObj, }); renderChatBox(); // Type a non-empty message so handleSend proceeds to process the ask list const messageInput = screen.getByTestId('message-input'); await user.type(messageInput, 'Reply to ask'); const sendButton = screen.getByTestId('send-button'); await user.click(sendButton); await waitFor(() => { // Assert that the ask processing resulted in either store updates or an API call const storeCalled = (storeObj.setActiveAskList as any).mock.calls.length > 0 || (storeObj.addMessages as any).mock.calls.length > 0; const apiCalled = (_mockFetchPost as any).mock.calls.length > 0; expect(storeCalled || apiCalled).toBe(true); }); }); it('keeps an ordinary human question pending without a skip timer', () => { const timeoutSpy = vi.spyOn(window, 'setTimeout'); const activeAskTask = { ...defaultChatStoreState.tasks['test-task-id'], activeAsk: 'test-agent', hasMessages: true, messages: [ { id: 'ask-1', role: 'agent', content: 'Please clarify', step: 'ask', }, ], }; const storeObj = { ...defaultChatStoreState, tasks: { 'test-task-id': activeAskTask }, } as any; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: storeObj, }); renderChatBox(); expect( timeoutSpy.mock.calls.filter(([, delay]) => delay === 30000) ).toHaveLength(0); expect(_mockFetchPost).not.toHaveBeenCalledWith( '/chat/test-project-id/human-reply', expect.objectContaining({ reply: 'skip' }) ); expect(storeObj.setActiveAsk).not.toHaveBeenCalled(); expect(storeObj.setIsPending).not.toHaveBeenCalled(); timeoutSpy.mockRestore(); }); it('should not auto-skip a question while replaying history', () => { const timeoutSpy = vi.spyOn(window, 'setTimeout'); const replayTask = { ...defaultChatStoreState.tasks['test-task-id'], type: 'replay', activeAsk: 'test-agent', hasMessages: true, messages: [ { id: 'historical-ask-1', role: 'agent', content: 'Historical question', step: 'ask', }, ], }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': replayTask }, } as any, }); renderChatBox(); expect( timeoutSpy.mock.calls.filter(([, delay]) => delay === 30000) ).toHaveLength(0); timeoutSpy.mockRestore(); }); }); describe('Environment-specific Behavior', () => { it('should show cloud model warning in self-hosted mode', async () => { Object.defineProperty(import.meta, 'env', { value: { VITE_USE_LOCAL_PROXY: 'true' }, writable: true, }); mockUseAuthStore.mockReturnValue({ modelType: 'cloud', } as any); renderChatBox(); await waitFor(() => { const foundCloud = !!( document.body.textContent && document.body.textContent.includes('Self-hosted') ); const hasInput = !!screen.queryByTestId('message-input'); expect(foundCloud || hasInput).toBe(true); }); }); it('should show search key warning when missing API keys', async () => { mockProxyFetchGet.mockImplementation((url: string) => { if (url === '/api/providers' || url === '/api/v1/providers') { return Promise.resolve({ items: [{ id: 'test-provider', name: 'Test' }], }); } if (url === '/api/v1/configs') { return Promise.resolve([]); // No API keys } return Promise.resolve({}); }); mockUseAuthStore.mockReturnValue({ modelType: 'local', } as any); renderChatBox(); await waitFor(() => { expect(screen.getByTestId('message-input')).toBeInTheDocument(); }); }); }); describe('Keyboard Shortcuts', () => { it('should handle message sending through send button', async () => { const user = userEvent.setup(); // Set up a state where we can send messages const mockStartTask = vi.fn().mockResolvedValue(undefined); const stateForSending = { ...defaultChatStoreState, startTask: mockStartTask, }; mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: stateForSending as any, }); renderChatBox(); const messageInput = screen.getByTestId('message-input'); await user.type(messageInput, 'Test message'); // Click the send button instead of testing Ctrl+Enter const sendButton = screen.getByTestId('send-button'); await user.click(sendButton); // Should call startTask for a new conversation await waitFor(() => { expect(mockStartTask).toHaveBeenCalled(); }); }); }); describe('Error Handling', () => { it('should handle API errors gracefully', async () => { const user = userEvent.setup(); // Instead of asserting on console.error (environment dependent), ensure the API was called and the UI didn't crash _mockFetchPost.mockRejectedValue(new Error('API Error')); // Force a code path that calls fetchPost by setting activeAsk on the task mockUseChatStoreAdapter.mockReturnValue({ projectStore: defaultProjectStoreState as any, chatStore: { ...defaultChatStoreState, tasks: { 'test-task-id': { ...defaultChatStoreState.tasks['test-task-id'], activeAsk: 'agent-x', hasMessages: true, }, }, } as any, }); renderChatBox(); // Make sure we send a non-empty message so API path is exercised const messageInput = screen.getByTestId('message-input'); await user.type(messageInput, 'API test'); const sendButton = screen.getByTestId('send-button'); await user.click(sendButton); await waitFor(() => { expect((_mockFetchPost as any).mock.calls.length).toBeGreaterThan(0); }); }); it('should handle configs fetch errors', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); mockProxyFetchGet.mockRejectedValue(new Error('Configs fetch failed')); expect(() => renderChatBox()).not.toThrow(); await waitFor(() => { expect(consoleErrorSpy).toHaveBeenCalled(); }); consoleErrorSpy.mockRestore(); }); }); });