// ========= 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. ========= import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { generateUniqueId } from '../../../src/lib'; // Import proxy mock to enable API mocking import '../../mocks/proxy.mock'; // Also Mock authStore & sse import '../../mocks/authStore.mock'; import '../../mocks/sse.mock'; // Import chat store to ensure it's available import '../../../src/store/chatStore'; import useChatStoreAdapter from '../../../src/hooks/useChatStoreAdapter'; import { replayActiveTask, replayProject } from '../../../src/lib'; import { useProjectStore } from '../../../src/store/projectStore'; import { createSSESequence, issue619SseSequence, mockFetchEventSource, } from '../../mocks/sse.mock'; // Mock navigate function const mockNavigate = vi.fn() as any; // Mock electron IPC (global as any).ipcRenderer = { invoke: vi.fn((channel) => { if (channel === 'get-system-language') return Promise.resolve('en'); if (channel === 'get-browser-port') return Promise.resolve(9222); if (channel === 'get-env-path') return Promise.resolve('/path/to/env'); if (channel === 'mcp-list') return Promise.resolve({}); if (channel === 'get-file-list') return Promise.resolve([]); return Promise.resolve(); }), }; // Mock window.electronAPI Object.defineProperty(window, 'electronAPI', { value: { uploadLog: vi.fn().mockResolvedValue(undefined), // Add other electronAPI methods as needed }, writable: true, }); describe('Integration Test: Replay Functionality', () => { let initialProjectId: string; let initialTaskId: string; let projectStoreResult: any; beforeEach(() => { vi.clearAllMocks(); mockNavigate.mockClear(); projectStoreResult = renderHook(() => useProjectStore()); //Reset projectStore projectStoreResult.result.current .getAllProjects() .forEach((project: any) => { projectStoreResult.result.current.removeProject(project.id); }); //Create initial Project for testing initialProjectId = projectStoreResult.result.current.createProject( 'Original Project', 'Testing replay functionality' ); expect(initialProjectId).toBeDefined(); // Get chatStore (automatically created) const chatStore = projectStoreResult.result.current.getActiveChatStore(initialProjectId)!; expect(chatStore).toBeDefined(); initialTaskId = chatStore.getState().activeTaskId!; expect(initialTaskId).toBeDefined(); }); afterEach(() => { vi.clearAllMocks(); }); it('should create replay project with correct taskId == projectId', async () => { const { result, rerender } = renderHook(() => useChatStoreAdapter()); // Setup replay events sequence const replayEventSequence = createSSESequence([ { event: { step: 'confirmed', data: { question: 'Build a calculator app' }, }, delay: 100, }, { event: { step: 'to_sub_tasks', data: { summary_task: 'Calculator App|Build a simple calculator', sub_tasks: [ { id: 'replay-task-1', content: 'Create UI components', status: '', }, { id: 'replay-task-2', content: 'Implement calculator logic', status: '', }, ], }, }, delay: 200, }, { event: { step: 'end', data: '--- Replay Task Result ---\nCalculator app replay completed!', }, delay: 300, }, ]); // Mock SSE for replay mockFetchEventSource.mockImplementation( async (url: string, options: any) => { console.log('SSE URL called:', url); if (url.includes('/api/chat/steps/playback/') && options.onmessage) { await replayEventSequence(options.onmessage); } } ); // Run replayProject await act(async () => { await replayProject( result.current.projectStore, mockNavigate, generateUniqueId(), //Gotten from api 'Build a calculator app', 'test-history-id' ); }); // Verify replay project was created await waitFor( () => { rerender(); const { projectStore } = result.current; const projects = projectStore.getAllProjects(); // Should have original project + replay project expect(projects).toHaveLength(2); // Find the replay project const replayProject = projects.find((p: any) => p.name.includes('Replay Project') ); expect(replayProject).toBeDefined(); expect(replayProject?.name).toBe( 'Replay Project Build a calculator app' ); // Test critical requirement: taskId should equal projectId for replay const replayChatStores = projectStore.getAllChatStores( replayProject!.id ); //Initial one is empty one - TODO: Reuse the empty one (even if projectid isgiven) expect(replayChatStores).toHaveLength(2); const replayChatStore = replayChatStores[1].chatStore; const replayTaskId = replayChatStore.getState().activeTaskId; // The main test: taskId should equal the projectId passed to replayProject // In this case we passed generateUniqueId() as the projectId expect(replayTaskId).toBeDefined(); expect(replayTaskId).not.toBe(initialProjectId); // Should be different from initial project // Verify the replay task has correct properties const replayTask = replayChatStore.getState().tasks[replayTaskId]; expect(replayTask).toBeDefined(); expect(replayTask.type).toBe('replay'); expect(replayTask.messages[0].content).toBe('Build a calculator app'); console.log('Replay Project ID:', replayProject!.id); console.log('Replay Task ID:', replayTaskId); console.log('Original Project ID:', initialProjectId); }, { timeout: 2000 } ); // Verify navigation was called expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/' }); }); it('should not append chatStore during replay (appendingChatStore logic)', async () => { const { result, rerender } = renderHook(() => useChatStoreAdapter()); const projectStoreResult = renderHook(() => useProjectStore()); // Setup replay events with multiple steps to test appendingChatStore logic const replayEventSequence = createSSESequence([ { event: { step: 'confirmed', data: { question: 'Build a todo app' }, }, delay: 100, }, { event: { step: 'to_sub_tasks', data: { summary_task: 'Todo App|Build a todo application', sub_tasks: [ { id: 'todo-1', content: 'Design interface', status: '' }, { id: 'todo-2', content: 'Implement logic', status: '' }, ], }, }, delay: 200, }, { event: { step: 'progress', data: 'Processing todo app requirements...', }, delay: 300, }, { event: { step: 'end', data: '--- Todo App Replay Result ---\nTodo app replay finished!', }, delay: 400, }, ]); mockFetchEventSource.mockImplementation( async (url: string, options: any) => { if (url.includes('/api/chat/steps/playback/') && options.onmessage) { await replayEventSequence(options.onmessage); } } ); // Get initial project count const initialProjectCount = projectStoreResult.result.current.getAllProjects().length; // Run replay await act(async () => { await replayProject( projectStoreResult.result.current, mockNavigate, generateUniqueId(), 'Build a todo app', 'test-history-id-2' ); }); // Wait for replay to complete await waitFor( () => { rerender(); const { projectStore } = result.current; const projects = projectStore.getAllProjects(); // We should have original project + replay project (so +1) expect(projects).toHaveLength(initialProjectCount + 1); const replayProject = projects.find((p: any) => p.name.includes('Replay Project Build a todo app') ); expect(replayProject).toBeDefined(); // Critical test: Should have exactly ONE chatStore in replay project // This tests that appendingChatStore logic prevented additional chatStores const replayChatStores = projectStore.getAllChatStores( replayProject!.id ); expect(replayChatStores).toHaveLength(2); // Verify the single chatStore has the replay task const replayChatStore = replayChatStores[1].chatStore; const activeTaskId = replayChatStore.getState().activeTaskId; const task = activeTaskId ? replayChatStore.getState().tasks[activeTaskId] : null; expect(task).toBeDefined(); expect(task?.summaryTask).toBe('Todo App|Build a todo application'); console.log('Replay ChatStore count:', replayChatStores.length); console.log('Should be exactly 1 (no appending during replay)'); }, { timeout: 3000 } ); }); it('should handle startTask on same project after replay completes', async () => { const { result, rerender } = renderHook(() => useChatStoreAdapter()); const projectStoreResult = renderHook(() => useProjectStore()); // Step 1: Complete a replay first const replayEventSequence = createSSESequence([ { event: { step: 'confirmed', data: { question: 'Initial replay task' }, }, delay: 100, }, { event: { step: 'end', data: '--- Initial Replay Completed ---', }, delay: 200, }, ]); mockFetchEventSource.mockImplementation( async (url: string, options: any) => { if (url.includes('/api/chat/steps/playback/') && options.onmessage) { await replayEventSequence(options.onmessage); } } ); // Run initial replay await act(async () => { await replayProject( projectStoreResult.result.current, mockNavigate, generateUniqueId(), 'Initial replay task', 'replay-history-id' ); }); // Wait for replay to complete await waitFor( () => { const projects = projectStoreResult.result.current.getAllProjects(); const replayProj = projects.find((p) => p.name.includes('Replay Project') ); expect(replayProj).toBeDefined(); }, { timeout: 2000 } ); // Step 2: Setup new SSE events for post-replay startTask const postReplayEventSequence = createSSESequence([ { event: { step: 'to_sub_tasks', data: { summary_task: 'Post Replay Task|New task after replay', sub_tasks: [ { id: 'post-1', content: 'New task component', status: '' }, ], }, }, delay: 100, }, { event: { step: 'end', data: '--- Post Replay Task Completed ---', }, delay: 200, }, ]); // Update mock for post-replay events mockFetchEventSource.mockImplementation( async (url: string, options: any) => { if (!url.includes('/api/chat/steps/playback/') && options.onmessage) { await postReplayEventSequence(options.onmessage); } } ); // Step 3: Call startTask on replay project after replay completes await act(async () => { rerender(); const { chatStore } = result.current; // Should be connected to the replay project now expect(chatStore).toBeDefined(); const currentTaskId = chatStore.activeTaskId; expect(currentTaskId).toBeDefined(); // Start a new task on the replay project await chatStore.startTask( currentTaskId, undefined, undefined, undefined, 'New task after replay completion' ); rerender(); }); // Step 4: Verify new chatStore was created for post-replay task await waitFor( () => { rerender(); const { chatStore: newChatStore, projectStore } = result.current; // Should have a new chatStore for the post-replay task expect(newChatStore).toBeDefined(); const activeTaskId = newChatStore.activeTaskId; const activeTask = newChatStore.tasks[activeTaskId]; expect(activeTask).toBeDefined(); expect(activeTask.messages[0].content).toBe( 'New task after replay completion' ); expect(activeTask.summaryTask).toBe( 'Post Replay Task|New task after replay' ); // Verify we now have 2 chatStores in the replay project (replay + post-replay task) const allChatStores = projectStore.getAllChatStores( projectStore.activeProjectId ); // Expected: on createProject + original replay chatStore + new post-replay chatStore = 3 expect(allChatStores).toHaveLength(3); console.log('Post-replay chatStore count:', allChatStores.length); console.log('Successfully created new chatStore after replay'); }, { timeout: 2000 } ); }); it('should handle parallel startTask during replay (separate chatStores)', async () => { const { result, rerender } = renderHook(() => useChatStoreAdapter()); const projectStoreResult = renderHook(() => useProjectStore()); // Setup automatic SSE for both replay and parallel tasks const replayEventSequence = createSSESequence([ { event: { step: 'confirmed', data: { question: 'Long running replay task' }, }, delay: 100, }, { event: { step: 'end', data: '--- Replay Task Completed ---', }, delay: 500, // Longer delay to allow parallel task to start }, ]); const parallelEventSequence = createSSESequence([ { event: { step: 'to_sub_tasks', data: { summary_task: 'Parallel Task|Running alongside replay', sub_tasks: [ { id: 'parallel-1', content: 'Parallel component', status: '' }, ], }, }, delay: 100, }, { event: { step: 'end', data: '--- Parallel Task Completed ---', }, delay: 200, }, ]); // Mock SSE to handle both replay and parallel tasks automatically mockFetchEventSource.mockImplementation( async (url: string, options: any) => { console.log('Mock SSE called with URL:', url); if (url.includes('/api/chat/steps/playback/') && options.onmessage) { // This is replay SSE console.log('Processing replay events'); await replayEventSequence(options.onmessage); } else if (options.onmessage) { // This is parallel startTask SSE console.log('Processing parallel task events'); await parallelEventSequence(options.onmessage); } } ); // Step 1: Start replay await act(async () => { await replayProject( projectStoreResult.result.current, mockNavigate, generateUniqueId(), 'Long running replay task', 'long-replay-history' ); }); // Verify replay started await waitFor( () => { const projects = projectStoreResult.result.current.getAllProjects(); const replayProj = projects.find((p) => p.name.includes('Replay Project') ); expect(replayProj).toBeDefined(); }, { timeout: 1000 } ); // Step 2: While replay is running, start parallel task on same project await act(async () => { rerender(); const { chatStore } = result.current; expect(chatStore).toBeDefined(); const currentTaskId = chatStore.activeTaskId; // Start parallel task await chatStore.startTask( currentTaskId, undefined, undefined, undefined, 'Parallel task during replay' ); rerender(); }); // Step 3: Verify both tasks completed independently await waitFor( () => { rerender(); const { projectStore } = result.current; const allChatStores = projectStore.getAllChatStores( projectStore.activeProjectId ); // Should have exactly 2 chatStores: onCreate + replay + parallel expect(allChatStores).toHaveLength(3); // Get both chatStores and verify they have different content const chatStore1 = allChatStores[1].chatStore; const chatStore2 = allChatStores[2].chatStore; const task1 = chatStore1.getState().tasks[chatStore1.getState().activeTaskId]; const task2 = chatStore2.getState().tasks[chatStore2.getState().activeTaskId]; expect(task1).toBeDefined(); expect(task2).toBeDefined(); // Verify they have different messages const contents = [task1.messages[0].content, task2.messages[0].content]; expect(contents).toContain('Long running replay task'); expect(contents).toContain('Parallel task during replay'); console.log( 'Parallel startTask during replay test completed successfully' ); console.log('Both tasks ran independently with separate chatStores'); }, { timeout: 3000 } ); }); }); //https://github.com/eigent-ai/eigent/issues/619 - Two task boxes describe('Issue #619 - Duplicate Task Boxes after replay', () => { beforeEach(() => { vi.clearAllMocks(); const { result } = renderHook(() => useProjectStore()); //Reset projectStore result.current.getAllProjects().forEach((project) => { result.current.removeProject(project.id); }); }); afterEach(() => { vi.clearAllMocks(); }); it('should create a separate chatStore for each replay', async () => { const { result, rerender } = renderHook(() => useChatStoreAdapter()); let sseCallCount = 0; // Step 0: First simulate a replay mechanism to set up the scenario const replaySequence = createSSESequence([ { event: { step: 'confirmed', data: { question: 'Previous calendar task replay' }, }, delay: 50, }, { event: { step: 'to_sub_tasks', data: { summary_task: 'Calendar Replay|Previous calendar interaction', sub_tasks: [ { id: 'replay-cal-1', content: 'Check calendar access', status: 'completed', }, { id: 'replay-cal-2', content: 'Fetch meeting data', status: 'completed', }, ], }, }, delay: 100, }, { event: { step: 'end', data: '--- Previous Calendar Task Replay Complete ---\nFound 3 upcoming meetings', }, delay: 150, }, ]); // Mock SSE stream with controlled events - delay setup until after task IDs are available mockFetchEventSource.mockImplementation( async (url: string, options: any) => { sseCallCount++; console.log(`SSE Call #${sseCallCount} initiated`); if (options.onmessage) { // First simulate replay of previous event to establish context if (sseCallCount === 1 && url.includes('/api/chat/steps/playback/')) { console.log( 'Simulating replay mechanism for previous calendar task' ); await replaySequence(options.onmessage); return; } // Now send the new task events with actual task IDs const immediateSequence = createSSESequence(issue619SseSequence); await immediateSequence(options.onmessage); } } ); // Simulate the replay mechanism first console.log('Starting replay simulation to establish context...'); await act(async () => { await replayProject( result.current.projectStore, mockNavigate, generateUniqueId(), 'Previous calendar task replay', 'calendar-replay-history' ); rerender(); }); // Wait for replay to complete and verify it's established await waitFor( () => { rerender(); const { projectStore } = result.current; const projects = projectStore.getAllProjects(); const replayProject = projects.find((p: any) => p.name.includes('Replay Project') ); expect(replayProject).toBeDefined(); console.log('Replay mechanism completed - context established'); }, { timeout: 1000 } ); // Get initial state const { chatStore: initialChatStore, projectStore } = result.current; const projectId = projectStore.activeProjectId as string; const initiatorTaskId = initialChatStore.activeTaskId; // Verify initial queue is empty expect(projectStore.getProjectById(projectId)?.queuedMessages).toEqual([]); // Step 1: Start first task await act(async () => { const userMessage = 'Please help me check Google Calendar when is the next meeting, what kind of meeting it is, and who is attending the meeting.'; await initialChatStore.startTask( initiatorTaskId, undefined, undefined, undefined, userMessage ); rerender(); }); // Wait for task to start and reach 'to_sub_tasks' phase (task becomes busy) await waitFor( () => { rerender(); const { chatStore, projectStore: _projectStore } = result.current; const taskId = chatStore.activeTaskId; const task = chatStore.tasks[taskId]; // Task should have subtasks (making it busy) expect(task.summaryTask).toBe( 'Task|Please help me check Google Calendar when is the next meeting, what kind of meeting it is, and who is attending the meeting.' ); console.log('Task info is ', task.taskInfo); //Bcz of newTaskInfo { id: '', content: '', status: '' } we have 2 items expect(task.taskInfo).toHaveLength(2); console.log('Task reached to_sub_tasks phase - now busy'); }, { timeout: 1500 } ); //Waitfor end sse await waitFor( () => { rerender(); const { chatStore: finalChatStore, projectStore: _finalProjectStore } = result.current; const finalTaskId = finalChatStore.activeTaskId; const finalTask = finalChatStore.tasks[finalTaskId]; expect(finalTask.status).toBe('finished'); }, { timeout: 10000 } ); // Step 7: Verify final state const { chatStore: finalChatStore, projectStore: finalProjectStore } = result.current; const _finalProject = finalProjectStore.getProjectById(projectId); // Verify task completed successfully const finalTaskId = finalChatStore.activeTaskId; const finalTask = finalChatStore.tasks[finalTaskId]; expect(finalTask.status).toBe('finished'); expect(finalTask.summaryTask).toBe( 'Task|Please help me check Google Calendar when is the next meeting, what kind of meeting it is, and who is attending the meeting.' ); console.log( 'Test completed - queue management verified: one task processed, one remains' ); }); it('should have correct first question on replayActiveTask', async () => { const { result, rerender } = renderHook(() => useChatStoreAdapter()); const _projectStoreResult = renderHook(() => useProjectStore()); let sseCallCount = 0; const originalUserMessage = 'Please help me check Google Calendar when is the next meeting, what kind of meeting it is, and who is attending the meeting.'; // Step 1: Create initial task with specific user message const initialSequence = createSSESequence([ { event: { step: 'confirmed', data: { question: originalUserMessage }, }, delay: 100, }, { event: { step: 'to_sub_tasks', data: { summary_task: 'Calendar Task|Check upcoming Google Calendar meetings', sub_tasks: [ { id: 'cal-1', content: 'Access Google Calendar', status: 'completed', }, { id: 'cal-2', content: 'Fetch meeting details', status: 'completed', }, ], }, }, delay: 200, }, { event: { step: 'end', data: '--- Calendar Task Complete ---\nFound your next meeting: Team Standup at 2:00 PM', }, delay: 300, }, ]); // Step 2: Setup replay sequence that should have same first question const replaySequence = createSSESequence([ { event: { step: 'confirmed', data: { question: 'Fall Back question' }, }, delay: 100, }, { event: { step: 'to_sub_tasks', data: { summary_task: 'Calendar Task Replay|Replaying calendar meeting check', sub_tasks: [ { id: 'replay-cal-1', content: 'Access Google Calendar (replay)', status: 'completed', }, { id: 'replay-cal-2', content: 'Fetch meeting details (replay)', status: 'completed', }, ], }, }, delay: 200, }, { event: { step: 'end', data: '--- Calendar Task Replay Complete ---\nReplayed: Found your next meeting', }, delay: 300, }, ]); // Mock SSE to handle both initial task and replay mockFetchEventSource.mockImplementation( async (url: string, options: any) => { sseCallCount++; console.log(`SSE Call #${sseCallCount}: ${url}`); if (options.onmessage) { if (sseCallCount === 1) { // First call: initial task console.log('Processing initial task events'); await initialSequence(options.onmessage); } else if (url.includes('/api/chat/steps/playback/')) { // Subsequent calls: replay console.log('Processing replay events'); await replaySequence(options.onmessage); } } } ); // Step 3: Start initial task await act(async () => { const { chatStore } = result.current; const taskId = chatStore.activeTaskId; await chatStore.startTask( taskId, undefined, undefined, undefined, originalUserMessage ); rerender(); }); // Wait for initial task to complete await waitFor( () => { rerender(); const { chatStore } = result.current; const taskId = chatStore.activeTaskId; const task = chatStore.tasks[taskId]; expect(task.status).toBe('finished'); expect(task.messages[0].content).toBe(originalUserMessage); console.log( 'Initial task completed with user message:', task.messages[0].content ); }, { timeout: 2000 } ); // Step 4: Get the completed chatStore for replay const { chatStore: completedChatStore, projectStore } = result.current; const completedTaskId = completedChatStore.activeTaskId; const completedTask = completedChatStore.tasks[completedTaskId]; // Verify we have the correct initial state expect(completedTask.messages[0].content).toBe(originalUserMessage); expect(completedTask.status).toBe('finished'); // Step 5: Call replayActiveTask using the completed chatStore await act(async () => { await replayActiveTask(completedChatStore, projectStore, mockNavigate); rerender(); }); // Step 6: Wait for replay to complete and verify first question matches await waitFor( () => { rerender(); const { projectStore: updatedProjectStore } = result.current; const projects = updatedProjectStore.getAllProjects(); // Find the replay project const replayProject = projects.find((p: any) => p.name.includes('Replay Project') ); expect(replayProject).toBeDefined(); // Get the replay chatStore const replayChatStores = updatedProjectStore.getAllChatStores( replayProject!.id ); expect(replayChatStores.length).toBeGreaterThan(1); const replayChatStore = replayChatStores[1].chatStore; // Skip the empty initial one const replayTaskId = replayChatStore.getState().activeTaskId; const replayTask = replayChatStore.getState().tasks[replayTaskId]; // THE MAIN TEST: First question in replay should match original user message expect(replayTask).toBeDefined(); expect(replayTask.messages[0].content).toBe(originalUserMessage); expect(replayTask.type).toBe('replay'); console.log( '✅ Replay first question matches original:', replayTask.messages[0].content ); console.log('✅ Original user message:', originalUserMessage); console.log( '✅ Test passed: replayActiveTask preserves correct first question' ); }, { timeout: 3000 } ); // Verify navigation was called for replay expect(mockNavigate).toHaveBeenCalledWith('/'); }); });