/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { ChatRecordingService, type ConversationRecord, type ToolCallRecord, type MessageRecord, } from './chatRecordingService.js'; import { CoreToolCallStatus } from '../scheduler/types.js'; import type { Content, Part } from '@google/genai'; import type { Config } from '../config/config.js'; import { getProjectHash } from '../utils/paths.js'; vi.mock('../utils/paths.js'); vi.mock('node:crypto', () => { let count = 0; return { randomUUID: vi.fn(() => `test-uuid-${count++}`), createHash: vi.fn(() => ({ update: vi.fn(() => ({ digest: vi.fn(() => 'mocked-hash'), })), })), }; }); describe('ChatRecordingService', () => { let chatRecordingService: ChatRecordingService; let mockConfig: Config; let testTempDir: string; beforeEach(async () => { testTempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'chat-recording-test-'), ); mockConfig = { get config() { return this; }, toolRegistry: { getTool: vi.fn(), }, promptId: 'test-session-id', getSessionId: vi.fn().mockReturnValue('test-session-id'), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), storage: { getProjectTempDir: vi.fn().mockReturnValue(testTempDir), }, getModel: vi.fn().mockReturnValue('gemini-pro'), getDebugMode: vi.fn().mockReturnValue(false), getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn().mockReturnValue({ displayName: 'Test Tool', description: 'A test tool', isOutputMarkdown: false, }), }), } as unknown as Config; vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); chatRecordingService = new ChatRecordingService(mockConfig); }); afterEach(async () => { vi.restoreAllMocks(); if (testTempDir) { await fs.promises.rm(testTempDir, { recursive: true, force: true }); } }); describe('initialize', () => { it('should create a new session if none is provided', () => { chatRecordingService.initialize(); chatRecordingService.recordMessage({ type: 'user', content: 'ping', model: 'm', }); const chatsDir = path.join(testTempDir, 'chats'); expect(fs.existsSync(chatsDir)).toBe(true); const files = fs.readdirSync(chatsDir); expect(files.length).toBeGreaterThan(0); expect(files[0]).toMatch(/^session-.*-test-ses\.json$/); }); it('should include the conversation kind when specified', () => { chatRecordingService.initialize(undefined, 'subagent'); chatRecordingService.recordMessage({ type: 'user', content: 'ping', model: 'm', }); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.kind).toBe('subagent'); }); it('should resume from an existing session if provided', () => { const chatsDir = path.join(testTempDir, 'chats'); fs.mkdirSync(chatsDir, { recursive: true }); const sessionFile = path.join(chatsDir, 'session.json'); const initialData = { sessionId: 'old-session-id', projectHash: 'test-project-hash', messages: [], }; fs.writeFileSync(sessionFile, JSON.stringify(initialData)); chatRecordingService.initialize({ filePath: sessionFile, conversation: { sessionId: 'old-session-id', } as ConversationRecord, }); const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8')); expect(conversation.sessionId).toBe('old-session-id'); }); }); describe('recordMessage', () => { beforeEach(() => { chatRecordingService.initialize(); }); it('should record a new message', () => { chatRecordingService.recordMessage({ type: 'user', content: 'Hello', displayContent: 'User Hello', model: 'gemini-pro', }); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.messages).toHaveLength(1); expect(conversation.messages[0].content).toBe('Hello'); expect(conversation.messages[0].displayContent).toBe('User Hello'); expect(conversation.messages[0].type).toBe('user'); }); it('should create separate messages when recording multiple messages', () => { chatRecordingService.recordMessage({ type: 'user', content: 'World', model: 'gemini-pro', }); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.messages).toHaveLength(1); expect(conversation.messages[0].content).toBe('World'); }); }); describe('recordThought', () => { it('should queue a thought', () => { chatRecordingService.initialize(); chatRecordingService.recordThought({ subject: 'Thinking', description: 'Thinking...', }); // @ts-expect-error private property expect(chatRecordingService.queuedThoughts).toHaveLength(1); // @ts-expect-error private property expect(chatRecordingService.queuedThoughts[0].subject).toBe('Thinking'); }); }); describe('recordMessageTokens', () => { beforeEach(() => { chatRecordingService.initialize(); }); it('should update the last message with token info', () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', model: 'gemini-pro', }); chatRecordingService.recordMessageTokens({ promptTokenCount: 1, candidatesTokenCount: 2, totalTokenCount: 3, cachedContentTokenCount: 0, }); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; const geminiMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; expect(geminiMsg.tokens).toEqual({ input: 1, output: 2, total: 3, cached: 0, thoughts: 0, tool: 0, }); }); it('should queue token info if the last message already has tokens', () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', model: 'gemini-pro', }); chatRecordingService.recordMessageTokens({ promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2, cachedContentTokenCount: 0, }); chatRecordingService.recordMessageTokens({ promptTokenCount: 2, candidatesTokenCount: 2, totalTokenCount: 4, cachedContentTokenCount: 0, }); // @ts-expect-error private property expect(chatRecordingService.queuedTokens).toEqual({ input: 2, output: 2, total: 4, cached: 0, thoughts: 0, tool: 0, }); }); it('should not write to disk when queuing tokens (no last gemini message)', () => { const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); // Clear spy call count after initialize writes the initial file writeFileSyncSpy.mockClear(); // No gemini message recorded yet, so tokens should only be queued chatRecordingService.recordMessageTokens({ promptTokenCount: 5, candidatesTokenCount: 10, totalTokenCount: 15, cachedContentTokenCount: 0, }); // writeFileSync should NOT have been called since we only queued expect(writeFileSyncSpy).not.toHaveBeenCalled(); // @ts-expect-error private property expect(chatRecordingService.queuedTokens).toEqual({ input: 5, output: 10, total: 15, cached: 0, thoughts: 0, tool: 0, }); writeFileSyncSpy.mockRestore(); }); it('should not write to disk when queuing tokens (last message already has tokens)', () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', model: 'gemini-pro', }); // First recordMessageTokens updates the message and writes to disk chatRecordingService.recordMessageTokens({ promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2, cachedContentTokenCount: 0, }); const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); writeFileSyncSpy.mockClear(); // Second call should only queue, NOT write to disk chatRecordingService.recordMessageTokens({ promptTokenCount: 2, candidatesTokenCount: 2, totalTokenCount: 4, cachedContentTokenCount: 0, }); expect(writeFileSyncSpy).not.toHaveBeenCalled(); writeFileSyncSpy.mockRestore(); }); it('should use in-memory cache and not re-read from disk on subsequent operations', () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', model: 'gemini-pro', }); const readFileSyncSpy = vi.spyOn(fs, 'readFileSync'); readFileSyncSpy.mockClear(); // These operations should all use the in-memory cache chatRecordingService.recordMessageTokens({ promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2, cachedContentTokenCount: 0, }); chatRecordingService.recordMessage({ type: 'gemini', content: 'Another response', model: 'gemini-pro', }); chatRecordingService.saveSummary('Test summary'); // readFileSync should NOT have been called since we use the in-memory cache expect(readFileSyncSpy).not.toHaveBeenCalled(); readFileSyncSpy.mockRestore(); }); }); describe('recordToolCalls', () => { beforeEach(() => { chatRecordingService.initialize(); }); it('should add new tool calls to the last message', () => { chatRecordingService.recordMessage({ type: 'gemini', content: '', model: 'gemini-pro', }); const toolCall: ToolCallRecord = { id: 'tool-1', name: 'testTool', args: {}, status: CoreToolCallStatus.AwaitingApproval, timestamp: new Date().toISOString(), }; chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; const geminiMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; expect(geminiMsg.toolCalls).toHaveLength(1); expect(geminiMsg.toolCalls![0].name).toBe('testTool'); }); it('should preserve dynamic description and NOT overwrite with generic one', () => { chatRecordingService.recordMessage({ type: 'gemini', content: '', model: 'gemini-pro', }); const dynamicDescription = 'DYNAMIC DESCRIPTION (e.g. Read file foo.txt)'; const toolCall: ToolCallRecord = { id: 'tool-1', name: 'testTool', args: {}, status: CoreToolCallStatus.Success, timestamp: new Date().toISOString(), description: dynamicDescription, }; chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; const geminiMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; expect(geminiMsg.toolCalls![0].description).toBe(dynamicDescription); }); it('should create a new message if the last message is not from gemini', () => { chatRecordingService.recordMessage({ type: 'user', content: 'call a tool', model: 'gemini-pro', }); const toolCall: ToolCallRecord = { id: 'tool-1', name: 'testTool', args: {}, status: CoreToolCallStatus.AwaitingApproval, timestamp: new Date().toISOString(), }; chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.messages).toHaveLength(2); expect(conversation.messages[1].type).toBe('gemini'); expect( (conversation.messages[1] as MessageRecord & { type: 'gemini' }) .toolCalls, ).toHaveLength(1); }); }); describe('deleteSession', () => { it('should delete the session file, tool outputs, session directory, and logs if they exist', () => { const sessionId = 'test-session-id'; const shortId = '12345678'; const chatsDir = path.join(testTempDir, 'chats'); const logsDir = path.join(testTempDir, 'logs'); const toolOutputsDir = path.join(testTempDir, 'tool-outputs'); const sessionDir = path.join(testTempDir, sessionId); fs.mkdirSync(chatsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(toolOutputsDir, { recursive: true }); fs.mkdirSync(sessionDir, { recursive: true }); // Create main session file with timestamp const sessionFile = path.join( chatsDir, `session-2023-01-01T00-00-${shortId}.json`, ); fs.writeFileSync(sessionFile, JSON.stringify({ sessionId })); const logFile = path.join(logsDir, `session-${sessionId}.jsonl`); fs.writeFileSync(logFile, '{}'); const toolOutputDir = path.join(toolOutputsDir, `session-${sessionId}`); fs.mkdirSync(toolOutputDir, { recursive: true }); // Call with shortId chatRecordingService.deleteSession(shortId); expect(fs.existsSync(sessionFile)).toBe(false); expect(fs.existsSync(logFile)).toBe(false); expect(fs.existsSync(toolOutputDir)).toBe(false); expect(fs.existsSync(sessionDir)).toBe(false); }); it('should delete subagent files and their logs when parent is deleted', () => { const parentSessionId = '12345678-session-id'; const shortId = '12345678'; const subagentSessionId = 'subagent-session-id'; const chatsDir = path.join(testTempDir, 'chats'); const logsDir = path.join(testTempDir, 'logs'); const toolOutputsDir = path.join(testTempDir, 'tool-outputs'); fs.mkdirSync(chatsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(toolOutputsDir, { recursive: true }); // Create parent session file const parentFile = path.join( chatsDir, `session-2023-01-01T00-00-${shortId}.json`, ); fs.writeFileSync( parentFile, JSON.stringify({ sessionId: parentSessionId }), ); // Create subagent session file const subagentFile = path.join( chatsDir, `session-2023-01-01T00-01-${shortId}.json`, ); fs.writeFileSync( subagentFile, JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }), ); // Create logs for both const parentLog = path.join(logsDir, `session-${parentSessionId}.jsonl`); fs.writeFileSync(parentLog, '{}'); const subagentLog = path.join( logsDir, `session-${subagentSessionId}.jsonl`, ); fs.writeFileSync(subagentLog, '{}'); // Create tool outputs for both const parentToolOutputDir = path.join( toolOutputsDir, `session-${parentSessionId}`, ); fs.mkdirSync(parentToolOutputDir, { recursive: true }); const subagentToolOutputDir = path.join( toolOutputsDir, `session-${subagentSessionId}`, ); fs.mkdirSync(subagentToolOutputDir, { recursive: true }); // Call with parent sessionId chatRecordingService.deleteSession(parentSessionId); expect(fs.existsSync(parentFile)).toBe(false); expect(fs.existsSync(subagentFile)).toBe(false); expect(fs.existsSync(parentLog)).toBe(false); expect(fs.existsSync(subagentLog)).toBe(false); expect(fs.existsSync(parentToolOutputDir)).toBe(false); expect(fs.existsSync(subagentToolOutputDir)).toBe(false); }); it('should delete by basename', () => { const sessionId = 'test-session-id'; const shortId = '12345678'; const chatsDir = path.join(testTempDir, 'chats'); const logsDir = path.join(testTempDir, 'logs'); fs.mkdirSync(chatsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true }); const basename = `session-2023-01-01T00-00-${shortId}`; const sessionFile = path.join(chatsDir, `${basename}.json`); fs.writeFileSync(sessionFile, JSON.stringify({ sessionId })); const logFile = path.join(logsDir, `session-${sessionId}.jsonl`); fs.writeFileSync(logFile, '{}'); // Call with basename chatRecordingService.deleteSession(basename); expect(fs.existsSync(sessionFile)).toBe(false); expect(fs.existsSync(logFile)).toBe(false); }); it('should not throw if session file does not exist', () => { expect(() => chatRecordingService.deleteSession('non-existent'), ).not.toThrow(); }); }); describe('recordDirectories', () => { beforeEach(() => { chatRecordingService.initialize(); }); it('should save directories to the conversation', () => { chatRecordingService.recordMessage({ type: 'user', content: 'ping', model: 'm', }); chatRecordingService.recordDirectories([ '/path/to/dir1', '/path/to/dir2', ]); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.directories).toEqual([ '/path/to/dir1', '/path/to/dir2', ]); }); it('should overwrite existing directories', () => { chatRecordingService.recordMessage({ type: 'user', content: 'ping', model: 'm', }); chatRecordingService.recordDirectories(['/old/dir']); chatRecordingService.recordDirectories(['/new/dir1', '/new/dir2']); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.directories).toEqual(['/new/dir1', '/new/dir2']); }); }); describe('rewindTo', () => { it('should rewind the conversation to a specific message ID', () => { chatRecordingService.initialize(); // Record some messages chatRecordingService.recordMessage({ type: 'user', content: 'msg1', model: 'm', }); chatRecordingService.recordMessage({ type: 'gemini', content: 'msg2', model: 'm', }); chatRecordingService.recordMessage({ type: 'user', content: 'msg3', model: 'm', }); const sessionFile = chatRecordingService.getConversationFilePath()!; let conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; const secondMsgId = conversation.messages[1].id; const result = chatRecordingService.rewindTo(secondMsgId); expect(result).not.toBeNull(); expect(result!.messages).toHaveLength(1); expect(result!.messages[0].content).toBe('msg1'); conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; expect(conversation.messages).toHaveLength(1); }); it('should return the original conversation if the message ID is not found', () => { chatRecordingService.initialize(); chatRecordingService.recordMessage({ type: 'user', content: 'msg1', model: 'm', }); const result = chatRecordingService.rewindTo('non-existent'); expect(result).not.toBeNull(); expect(result!.messages).toHaveLength(1); }); }); describe('ENOSPC (disk full) graceful degradation - issue #16266', () => { it('should disable recording and not throw when ENOSPC occurs during initialize', () => { const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => { throw enospcError; }); // Should not throw expect(() => chatRecordingService.initialize()).not.toThrow(); // Recording should be disabled (conversationFile set to null) expect(chatRecordingService.getConversationFilePath()).toBeNull(); mkdirSyncSpy.mockRestore(); }); it('should disable recording and not throw when ENOSPC occurs during writeConversation', () => { chatRecordingService.initialize(); const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; const writeFileSyncSpy = vi .spyOn(fs, 'writeFileSync') .mockImplementation(() => { throw enospcError; }); // Should not throw when recording a message expect(() => chatRecordingService.recordMessage({ type: 'user', content: 'Hello', model: 'gemini-pro', }), ).not.toThrow(); // Recording should be disabled (conversationFile set to null) expect(chatRecordingService.getConversationFilePath()).toBeNull(); writeFileSyncSpy.mockRestore(); }); it('should skip recording operations when recording is disabled', () => { chatRecordingService.initialize(); const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; const writeFileSyncSpy = vi .spyOn(fs, 'writeFileSync') .mockImplementationOnce(() => { throw enospcError; }); chatRecordingService.recordMessage({ type: 'user', content: 'First message', model: 'gemini-pro', }); // Reset mock to track subsequent calls writeFileSyncSpy.mockClear(); // Subsequent calls should be no-ops (not call writeFileSync) chatRecordingService.recordMessage({ type: 'user', content: 'Second message', model: 'gemini-pro', }); chatRecordingService.recordThought({ subject: 'Test', description: 'Test thought', }); chatRecordingService.saveSummary('Test summary'); // writeFileSync should not have been called for any of these expect(writeFileSyncSpy).not.toHaveBeenCalled(); writeFileSyncSpy.mockRestore(); }); it('should return null from getConversation when recording is disabled', () => { chatRecordingService.initialize(); const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; const writeFileSyncSpy = vi .spyOn(fs, 'writeFileSync') .mockImplementation(() => { throw enospcError; }); // Trigger ENOSPC chatRecordingService.recordMessage({ type: 'user', content: 'Hello', model: 'gemini-pro', }); // getConversation should return null when disabled expect(chatRecordingService.getConversation()).toBeNull(); expect(chatRecordingService.getConversationFilePath()).toBeNull(); writeFileSyncSpy.mockRestore(); }); it('should still throw for non-ENOSPC errors', () => { chatRecordingService.initialize(); const otherError = new Error('Permission denied'); (otherError as NodeJS.ErrnoException).code = 'EACCES'; const writeFileSyncSpy = vi .spyOn(fs, 'writeFileSync') .mockImplementation(() => { throw otherError; }); // Should throw for non-ENOSPC errors expect(() => chatRecordingService.recordMessage({ type: 'user', content: 'Hello', model: 'gemini-pro', }), ).toThrow('Permission denied'); // Recording should NOT be disabled for non-ENOSPC errors (file path still exists) expect(chatRecordingService.getConversationFilePath()).not.toBeNull(); writeFileSyncSpy.mockRestore(); }); }); describe('updateMessagesFromHistory', () => { beforeEach(() => { chatRecordingService.initialize(); }); it('should update tool results from API history (masking sync)', () => { // 1. Record an initial message and tool call chatRecordingService.recordMessage({ type: 'gemini', content: 'I will list the files.', model: 'gemini-pro', }); const callId = 'tool-call-123'; const originalResult = [{ text: 'a'.repeat(1000) }]; chatRecordingService.recordToolCalls('gemini-pro', [ { id: callId, name: 'list_files', args: { path: '.' }, result: originalResult, status: CoreToolCallStatus.Success, timestamp: new Date().toISOString(), }, ]); // 2. Prepare mock history with masked content const maskedSnippet = 'short preview'; const history: Content[] = [ { role: 'model', parts: [ { functionCall: { name: 'list_files', args: { path: '.' } } }, ], }, { role: 'user', parts: [ { functionResponse: { name: 'list_files', id: callId, response: { output: maskedSnippet }, }, }, ], }, ]; // 3. Trigger sync chatRecordingService.updateMessagesFromHistory(history); // 4. Verify disk content const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; const geminiMsg = conversation.messages[0]; if (geminiMsg.type !== 'gemini') throw new Error('Expected gemini message'); expect(geminiMsg.toolCalls).toBeDefined(); expect(geminiMsg.toolCalls![0].id).toBe(callId); // The implementation stringifies the response object const result = geminiMsg.toolCalls![0].result; if (!Array.isArray(result)) throw new Error('Expected array result'); const firstPart = result[0] as Part; expect(firstPart.functionResponse).toBeDefined(); expect(firstPart.functionResponse!.id).toBe(callId); expect(firstPart.functionResponse!.response).toEqual({ output: maskedSnippet, }); }); it('should preserve multi-modal sibling parts during sync', () => { chatRecordingService.initialize(); const callId = 'multi-modal-call'; const originalResult: Part[] = [ { functionResponse: { id: callId, name: 'read_file', response: { content: '...' }, }, }, { inlineData: { mimeType: 'image/png', data: 'base64...' } }, ]; chatRecordingService.recordMessage({ type: 'gemini', content: '', model: 'gemini-pro', }); chatRecordingService.recordToolCalls('gemini-pro', [ { id: callId, name: 'read_file', args: { path: 'image.png' }, result: originalResult, status: CoreToolCallStatus.Success, timestamp: new Date().toISOString(), }, ]); const maskedSnippet = ''; const history: Content[] = [ { role: 'user', parts: [ { functionResponse: { name: 'read_file', id: callId, response: { output: maskedSnippet }, }, }, { inlineData: { mimeType: 'image/png', data: 'base64...' } }, ], }, ]; chatRecordingService.updateMessagesFromHistory(history); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; const lastMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; const result = lastMsg.toolCalls![0].result as Part[]; expect(result).toHaveLength(2); expect(result[0].functionResponse!.response).toEqual({ output: maskedSnippet, }); expect(result[1].inlineData).toBeDefined(); expect(result[1].inlineData!.mimeType).toBe('image/png'); }); it('should handle parts appearing BEFORE the functionResponse in a content block', () => { chatRecordingService.initialize(); const callId = 'prefix-part-call'; chatRecordingService.recordMessage({ type: 'gemini', content: '', model: 'gemini-pro', }); chatRecordingService.recordToolCalls('gemini-pro', [ { id: callId, name: 'read_file', args: { path: 'test.txt' }, result: [], status: CoreToolCallStatus.Success, timestamp: new Date().toISOString(), }, ]); const history: Content[] = [ { role: 'user', parts: [ { text: 'Prefix metadata or text' }, { functionResponse: { name: 'read_file', id: callId, response: { output: 'file content' }, }, }, ], }, ]; chatRecordingService.updateMessagesFromHistory(history); const sessionFile = chatRecordingService.getConversationFilePath()!; const conversation = JSON.parse( fs.readFileSync(sessionFile, 'utf8'), ) as ConversationRecord; const lastMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; const result = lastMsg.toolCalls![0].result as Part[]; expect(result).toHaveLength(2); expect(result[0].text).toBe('Prefix metadata or text'); expect(result[1].functionResponse!.id).toBe(callId); }); it('should not write to disk when no tool calls match', () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response with no tool calls', model: 'gemini-pro', }); const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); writeFileSyncSpy.mockClear(); // History with a tool call ID that doesn't exist in the conversation const history: Content[] = [ { role: 'user', parts: [ { functionResponse: { name: 'read_file', id: 'nonexistent-call-id', response: { output: 'some content' }, }, }, ], }, ]; chatRecordingService.updateMessagesFromHistory(history); // No tool calls matched, so writeFileSync should NOT have been called expect(writeFileSyncSpy).not.toHaveBeenCalled(); writeFileSyncSpy.mockRestore(); }); }); describe('ENOENT (missing directory) handling', () => { it('should ensure directory exists before writing conversation file', () => { chatRecordingService.initialize(); const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync'); const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); chatRecordingService.recordMessage({ type: 'user', content: 'Hello after dir cleanup', model: 'gemini-pro', }); // mkdirSync should be called with the parent directory and recursive option const conversationFile = chatRecordingService.getConversationFilePath()!; expect(mkdirSyncSpy).toHaveBeenCalledWith( path.dirname(conversationFile), { recursive: true }, ); // mkdirSync should be called before writeFileSync const mkdirCallOrder = mkdirSyncSpy.mock.invocationCallOrder; const writeCallOrder = writeFileSyncSpy.mock.invocationCallOrder; const lastMkdir = mkdirCallOrder[mkdirCallOrder.length - 1]; const lastWrite = writeCallOrder[writeCallOrder.length - 1]; expect(lastMkdir).toBeLessThan(lastWrite); mkdirSyncSpy.mockRestore(); writeFileSyncSpy.mockRestore(); }); }); });