diff --git a/__tests__/AIClasses/BaseAIClass.test.ts b/__tests__/AIClasses/BaseAIClass.test.ts index fddf373..563b499 100644 --- a/__tests__/AIClasses/BaseAIClass.test.ts +++ b/__tests__/AIClasses/BaseAIClass.test.ts @@ -39,7 +39,7 @@ describe('BaseAIClass Shared Methods', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index 6a78d84..f4510f7 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -42,7 +42,7 @@ describe('Claude', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { diff --git a/__tests__/AIClasses/ClaudeConversationNamingService.test.ts b/__tests__/AIClasses/ClaudeConversationNamingService.test.ts index 6da9215..4e7c7c1 100644 --- a/__tests__/AIClasses/ClaudeConversationNamingService.test.ts +++ b/__tests__/AIClasses/ClaudeConversationNamingService.test.ts @@ -23,7 +23,7 @@ describe('ClaudeConversationNamingService', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { diff --git a/__tests__/AIClasses/CrossProviderIntegration.test.ts b/__tests__/AIClasses/CrossProviderIntegration.test.ts index 38e9a73..6924b7c 100644 --- a/__tests__/AIClasses/CrossProviderIntegration.test.ts +++ b/__tests__/AIClasses/CrossProviderIntegration.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { Gemini } from '../../AIClasses/Gemini/Gemini'; import { Claude } from '../../AIClasses/Claude/Claude'; import { OpenAI } from '../../AIClasses/OpenAI/OpenAI'; +import { Mistral } from '../../AIClasses/Mistral/Mistral'; import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; @@ -51,7 +52,7 @@ describe('Cross-Provider Integration - Thought Signature Support', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { @@ -1740,5 +1741,586 @@ describe('Cross-Provider Integration - Thought Signature Support', () => { const openaiResult = await (openai as any).extractContents(conversation); expect(openaiResult).toHaveLength(0); }); + + it('should handle Claude → Mistral provider switch with invalid tool call ID', async () => { + // Test the specific scenario from the bug report: + // Claude tool call with ID like "toolu_01EWDB2npEv6uLdEkkt56xxp" should be converted to text for Mistral + + const claudeToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'toolu_01EWDB2npEv6uLdEkkt56xxp', // Claude-style ID with underscores, wrong length + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'toolu_01EWDB2npEv6uLdEkkt56xxp' + }); + + const mistral = new Mistral(); + const result = await (mistral as any).extractContents([claudeToolCall]); + + // Should convert to legacy text format instead of trying to use the invalid ID + expect(result).toHaveLength(1); + expect(result[0].role).toBe(Role.Assistant); + expect(result[0].content).toContain('Historical tool call'); + expect(result[0].content).toContain('search_vault_files'); + expect(result[0].content).toContain('"name": "search_vault_files"'); + // Should NOT have tool_calls array (that would cause the API error) + expect(result[0].tool_calls).toBeUndefined(); + }); + }); + + describe('Mistral Cross-Provider Integration', () => { + describe('Mistral → Other Providers', () => { + it('should handle Mistral function call when switching to Claude', async () => { + // Mistral function call with valid 9-char alphanumeric ID + const mistralToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'abc123xyz', // Valid Mistral ID format + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'abc123xyz' + }); + + const claude = new Claude(); + const result = await (claude as any).extractContents([mistralToolCall]); + + // Claude should convert Mistral's function call to its tool_use format + expect(result).toHaveLength(1); + expect(result[0].content[0]).toEqual({ + type: 'tool_use', + id: 'abc123xyz', + name: 'search_vault_files', + input: { query: 'test' } + }); + }); + + it('should handle Mistral function call when switching to OpenAI', async () => { + // Mistral function call with valid ID + const mistralToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'def456ghi', // Valid Mistral ID format + name: 'read_file', + args: { path: 'test.md' } + } + }), + toolId: 'def456ghi' + }); + + const openai = new OpenAI(); + const result = await (openai as any).extractContents([mistralToolCall]); + + // OpenAI should convert Mistral's function call to its Responses API format + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'function_call', + call_id: 'def456ghi', + name: 'read_file', + arguments: '{"path":"test.md"}' + }); + }); + + it('should handle Mistral function call when switching to Gemini', async () => { + // Mistral function call with valid ID + const mistralToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'ghi789jkl', // Valid Mistral ID format + name: 'search_vault_files', + args: { query: 'project' } + } + }), + toolId: 'ghi789jkl' + }); + + const result = await (gemini as any).extractContents([mistralToolCall]); + + // Gemini should convert Mistral's function call to legacy text format + // because Gemini treats any function call with an ID as cross-provider + expect(result).toHaveLength(1); + expect(result[0].parts[0]).toHaveProperty('text'); + expect(result[0].parts[0].text).toContain('Historical tool call'); + expect(result[0].parts[0].text).toContain('search_vault_files'); + expect(result[0].parts[0].text).toContain('"name": "search_vault_files"'); + }); + }); + + describe('Other Providers → Mistral', () => { + it('should handle OpenAI function call when switching to Mistral', async () => { + // OpenAI function call with call_xyz format ID + const openaiToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'call_openai_123', // OpenAI-style ID + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'call_openai_123' + }); + + const mistral = new Mistral(); + const result = await (mistral as any).extractContents([openaiToolCall]); + + // Mistral should convert OpenAI's function call to legacy text format + expect(result).toHaveLength(1); + expect(result[0].role).toBe(Role.Assistant); + expect(result[0].content).toContain('Historical tool call'); + expect(result[0].content).toContain('search_vault_files'); + expect(result[0].tool_calls).toBeUndefined(); + }); + + it('should handle Claude function call when switching to Mistral', async () => { + // Claude function call with toolu_ format ID + const claudeToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'toolu_claude_123', // Claude-style ID + name: 'read_file', + args: { path: 'document.md' } + } + }), + toolId: 'toolu_claude_123' + }); + + const mistral = new Mistral(); + const result = await (mistral as any).extractContents([claudeToolCall]); + + // Mistral should convert Claude's function call to legacy text format + expect(result).toHaveLength(1); + expect(result[0].role).toBe(Role.Assistant); + expect(result[0].content).toContain('Historical tool call'); + expect(result[0].content).toContain('read_file'); + expect(result[0].tool_calls).toBeUndefined(); + }); + + it('should handle Gemini function call when switching to Mistral', async () => { + // Gemini function call with thoughtSignature but no ID + const geminiToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + name: 'search_vault_files', + args: { query: 'test' } + } + }), + thoughtSignature: 'gemini_signature_123==', + toolId: 'gemini_tool_1' + }); + + const mistral = new Mistral(); + const result = await (mistral as any).extractContents([geminiToolCall]); + + // Mistral should convert Gemini's function call to legacy text format + expect(result).toHaveLength(1); + expect(result[0].role).toBe(Role.Assistant); + expect(result[0].content).toContain('Historical tool call'); + expect(result[0].content).toContain('search_vault_files'); + expect(result[0].tool_calls).toBeUndefined(); + }); + }); + + describe('Mistral ID Validation', () => { + it('should accept valid 9-character alphanumeric Mistral IDs', async () => { + // Test various valid Mistral ID formats + const validIds = ['abc123xyz', 'ABC123XYZ', '123456789', 'aBcDeFgHi']; + + for (const validId of validIds) { + const mistralToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: validId, + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: validId + }); + + const mistral = new Mistral(); + const result = await (mistral as any).extractContents([mistralToolCall]); + + // Should use native tool_calls format for valid IDs + expect(result).toHaveLength(1); + expect(result[0].tool_calls).toBeDefined(); + expect(result[0].tool_calls![0].id).toBe(validId); + } + }); + + it('should reject Mistral IDs with invalid characters', async () => { + // Test IDs with special characters that Mistral doesn't support + const invalidIds = ['abc-123-xyz', 'abc_123_xyz', 'abc.123.xyz', 'abc 123 xyz']; + + for (const invalidId of invalidIds) { + const mistralToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: invalidId, + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: invalidId + }); + + const mistral = new Mistral(); + const result = await (mistral as any).extractContents([mistralToolCall]); + + // Should convert to legacy text format for invalid IDs + expect(result).toHaveLength(1); + expect(result[0].content).toContain('Historical tool call'); + expect(result[0].tool_calls).toBeUndefined(); + } + }); + + it('should reject Mistral IDs with invalid length', async () => { + // Test IDs that are too short or too long + const invalidLengthIds = ['abc123', 'abc12345678', 'a', 'abc12345678901234567890']; + + for (const invalidId of invalidLengthIds) { + const mistralToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: invalidId, + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: invalidId + }); + + const mistral = new Mistral(); + const result = await (mistral as any).extractContents([mistralToolCall]); + + // Should convert to legacy text format for invalid lengths + expect(result).toHaveLength(1); + expect(result[0].content).toContain('Historical tool call'); + expect(result[0].tool_calls).toBeUndefined(); + } + }); + }); + + describe('Mixed Conversations with Mistral', () => { + it('should handle conversation with Mistral, Claude, and OpenAI function calls', async () => { + const conversation = [ + new ConversationContent({ role: Role.User, content: 'Start workflow', displayContent: 'Start workflow' }), + + // Mistral function call + (() => { + const content = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'mistral12', // Valid Mistral ID + name: 'search_vault_files', + args: { query: 'project' } + } + }), + toolId: 'mistral12' + }); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'mistral12', + functionResponse: { name: 'search_vault_files', response: ['project.md'] } + }); + const content = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'mistral12' + }); + return content; + })(), + + new ConversationContent({ role: Role.Assistant, content: 'Found project.md' }), + new ConversationContent({ role: Role.User, content: 'Read it', displayContent: 'Read it' }), + + // Claude function call + (() => { + const content = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'toolu_claude_1', + name: 'read_file', + args: { path: 'project.md' } + } + }), + toolId: 'toolu_claude_1' + }); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'toolu_claude_1', + functionResponse: { name: 'read_file', response: { content: 'Project details' } } + }); + const content = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'toolu_claude_1' + }); + return content; + })(), + + new ConversationContent({ role: Role.Assistant, content: 'Project details...' }), + new ConversationContent({ role: Role.User, content: 'Summarize', displayContent: 'Summarize' }), + + // OpenAI function call + (() => { + const content = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'call_openai_1', + name: 'write_file', + args: { path: 'summary.md', content: 'Summary' } + } + }), + toolId: 'call_openai_1' + }); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'call_openai_1', + functionResponse: { name: 'write_file', response: { success: true } } + }); + const content = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'call_openai_1' + }); + return content; + })() + ]; + + // Test that all providers can handle this mixed conversation + const mistral = new Mistral(); + const mistralResult = await (mistral as any).extractContents(conversation); + expect(mistralResult.length).toBeGreaterThan(0); + + const claude = new Claude(); + const claudeResult = await (claude as any).extractContents(conversation); + expect(claudeResult.length).toBeGreaterThan(0); + + const openai = new OpenAI(); + const openaiResult = await (openai as any).extractContents(conversation); + expect(openaiResult.length).toBeGreaterThan(0); + + const geminiResult = await (gemini as any).extractContents(conversation); + expect(geminiResult.length).toBeGreaterThan(0); + }); + + it('should handle Mistral function responses when switching providers', async () => { + // Test that Mistral function responses are properly handled by other providers + const mistralToolCall = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'resp12345', // Valid Mistral ID + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'resp12345' + }); + + const mistralResponse = (() => { + const responseContent = JSON.stringify({ + id: 'resp12345', + functionResponse: { name: 'search_vault_files', response: ['file1.md', 'file2.md'] } + }); + const content = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'resp12345' + }); + return content; + })(); + + // Test Claude handling Mistral response + const claude = new Claude(); + const claudeResult = await (claude as any).extractContents([mistralToolCall, mistralResponse]); + expect(claudeResult.length).toBe(2); + + // Test OpenAI handling Mistral response + const openai = new OpenAI(); + const openaiResult = await (openai as any).extractContents([mistralToolCall, mistralResponse]); + expect(openaiResult.length).toBe(2); + + // Test Gemini handling Mistral response + const geminiResult = await (gemini as any).extractContents([mistralToolCall, mistralResponse]); + expect(geminiResult.length).toBe(2); + }); + }); + + describe('Mistral Round-Trip Testing', () => { + it('should handle Mistral → Claude → Mistral round-trip', async () => { + const conversation = [ + new ConversationContent({ role: Role.User, content: 'Search files', displayContent: 'Search files' }), + + // Mistral function call + (() => { + const content = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'mistral12', // Valid Mistral ID + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'mistral12' + }); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'mistral12', + functionResponse: { name: 'search_vault_files', response: ['file.md'] } + }); + const content = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'mistral12' + }); + return content; + })(), + + new ConversationContent({ role: Role.Assistant, content: 'Found file.md' }) + ]; + + // Mistral reads it initially + const mistral1 = new Mistral(); + const mistralResult1 = await (mistral1 as any).extractContents(conversation); + expect(mistralResult1.length).toBeGreaterThan(0); + + // Claude reads it (converts to its format) + const claude = new Claude(); + const claudeResult = await (claude as any).extractContents(conversation); + expect(claudeResult.length).toBeGreaterThan(0); + + // Mistral reads it again (should still work) + const mistral2 = new Mistral(); + const mistralResult2 = await (mistral2 as any).extractContents(conversation); + expect(mistralResult2.length).toBeGreaterThan(0); + }); + + it('should handle Mistral → OpenAI → Mistral round-trip', async () => { + const conversation = [ + new ConversationContent({ role: Role.User, content: 'Read file', displayContent: 'Read file' }), + + // Mistral function call + (() => { + const content = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'abc123def', // Valid Mistral ID + name: 'read_file', + args: { path: 'test.md' } + } + }), + toolId: 'abc123def' + }); + return content; + })(), + + (() => { + const responseContent = JSON.stringify({ + id: 'abc123def', + functionResponse: { name: 'read_file', response: { content: 'File contents' } } + }); + const content = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'abc123def' + }); + return content; + })(), + + new ConversationContent({ role: Role.Assistant, content: 'File contents...' }) + ]; + + // Mistral reads it initially + const mistral1 = new Mistral(); + const mistralResult1 = await (mistral1 as any).extractContents(conversation); + expect(mistralResult1.length).toBeGreaterThan(0); + + // OpenAI reads it (converts to its format) + const openai = new OpenAI(); + const openaiResult = await (openai as any).extractContents(conversation); + expect(openaiResult.length).toBeGreaterThan(0); + + // Mistral reads it again (should still work) + const mistral2 = new Mistral(); + const mistralResult2 = await (mistral2 as any).extractContents(conversation); + expect(mistralResult2.length).toBeGreaterThan(0); + }); + }); }); }); diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index bb56325..a158d3f 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -40,7 +40,7 @@ describe('Gemini', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { diff --git a/__tests__/AIClasses/GeminiConversationNamingService.test.ts b/__tests__/AIClasses/GeminiConversationNamingService.test.ts index c76ed20..11cb18f 100644 --- a/__tests__/AIClasses/GeminiConversationNamingService.test.ts +++ b/__tests__/AIClasses/GeminiConversationNamingService.test.ts @@ -23,7 +23,7 @@ describe('GeminiConversationNamingService', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { diff --git a/__tests__/AIClasses/Mistral.test.ts b/__tests__/AIClasses/Mistral.test.ts new file mode 100644 index 0000000..a6378f4 --- /dev/null +++ b/__tests__/AIClasses/Mistral.test.ts @@ -0,0 +1,951 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { Mistral } from '../../AIClasses/Mistral/Mistral'; +import { RegisterSingleton, Resolve, DeregisterAllServices } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { StreamingService } from '../../Services/StreamingService'; +import type { IPrompt } from '../../AIPrompts/IPrompt'; +import type VaultkeeperAIPlugin from '../../main'; +import { Conversation } from '../../Conversations/Conversation'; +import { ConversationContent } from '../../Conversations/ConversationContent'; +import { Role } from '../../Enums/Role'; +import { SettingsService } from '../../Services/SettingsService'; +import { AIProvider } from '../../Enums/ApiProvider'; +import { AbortService } from '../../Services/AbortService'; +import { Exception } from '../../Helpers/Exception'; + +describe('Mistral', () => { + let mistral: Mistral; + let mockStreamingService: any; + let mockPrompt: any; + let mockPlugin: any; + let mockSettingsService: any; + let abortService: AbortService; + + beforeEach(() => { + // Mock IPrompt + mockPrompt = { + systemInstruction: vi.fn().mockReturnValue('System instruction'), + userInstruction: vi.fn().mockResolvedValue('User instruction') + }; + RegisterSingleton(Services.IPrompt, mockPrompt); + + // Mock VaultkeeperAIPlugin + mockPlugin = {}; + RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin); + + // Mock SettingsService + mockSettingsService = { + settings: { + model: 'mistral-large-latest', + apiKeys: { + claude: 'test-claude-key', + openai: 'test-openai-key', + gemini: 'test-gemini-key', + mistral: 'test-mistral-key' + } + }, + getApiKeyForProvider: vi.fn((provider: AIProvider) => { + if (provider === AIProvider.Claude) return 'test-claude-key'; + if (provider === AIProvider.OpenAI) return 'test-openai-key'; + if (provider === AIProvider.Gemini) return 'test-gemini-key'; + if (provider === AIProvider.Mistral) return 'test-mistral-key'; + return ''; + }), + getApiKeyForCurrentModel: vi.fn(() => 'test-mistral-key') + }; + RegisterSingleton(Services.SettingsService, mockSettingsService); + + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + + // Mock StreamingService + mockStreamingService = { + streamRequest: vi.fn() + }; + RegisterSingleton(Services.StreamingService, mockStreamingService); + + // Mock IAIFileService + const mockFileService = { + refreshCache: vi.fn().mockResolvedValue(undefined), + listFiles: vi.fn().mockReturnValue([]), + uploadFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + deleteFiles: vi.fn().mockResolvedValue(undefined), + getSignedUrl: vi.fn((fileId: string) => `https://signed-url.com/${fileId}`) + }; + RegisterSingleton(Services.IAIFileService, mockFileService); + + mistral = new Mistral(); + }); + + afterEach(() => { + // Clear singleton registry to prevent memory leaks + DeregisterAllServices(); + }); + + describe('Constructor and Dependencies', () => { + it('should initialize with dependencies from DependencyService', () => { + expect(mistral).toBeDefined(); + }); + + it('should load API key from SettingsService', () => { + expect(mockSettingsService.getApiKeyForProvider(AIProvider.Mistral)).toBe('test-mistral-key'); + }); + + it('should resolve all required services', () => { + const prompt = Resolve(Services.IPrompt); + const plugin = Resolve(Services.VaultkeeperAIPlugin); + const settingsService = Resolve(Services.SettingsService); + const streaming = Resolve(Services.StreamingService); + + expect(prompt).toBe(mockPrompt); + expect(plugin).toBe(mockPlugin); + expect(settingsService).toBe(mockSettingsService); + expect(streaming).toBe(mockStreamingService); + }); + }); + + describe('parseStreamChunk', () => { + it('should handle [DONE] chunk as completion', () => { + const result = (mistral as any).parseStreamChunk('[DONE]'); + expect(result.content).toBe(''); + expect(result.isComplete).toBe(true); + }); + + it('should parse text content from delta', () => { + const chunk = JSON.stringify({ + choices: [{ + delta: { + content: 'Hello world' + }, + finish_reason: null + }] + }); + + const result = (mistral as any).parseStreamChunk(chunk); + expect(result.content).toBe('Hello world'); + expect(result.isComplete).toBe(false); + }); + + it('should handle tool call start and accumulate arguments', () => { + // Start tool call + const chunk1 = JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: 'call_123', + type: 'function', + function: { + name: 'search_vault_files', + arguments: '{"query":' + } + }] + }, + finish_reason: null + }] + }); + + const result1 = (mistral as any).parseStreamChunk(chunk1); + expect(result1.toolCallStarted).toBe('search_vault_files'); + + // Continue with more arguments + const chunk2 = JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + function: { + arguments: '"test"}' + } + }] + }, + finish_reason: null + }] + }); + + const result2 = (mistral as any).parseStreamChunk(chunk2); + expect(result2.content).toBe(''); + }); + + it('should finalize tool call on tool_calls finish_reason', () => { + // Start and accumulate tool call + const chunk1 = JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: 'call_123', + type: 'function', + function: { + name: 'search_vault_files', + arguments: '{"query":"test"}' + } + }] + }, + finish_reason: null + }] + }); + (mistral as any).parseStreamChunk(chunk1); + + // Complete with tool_calls finish reason + const chunk2 = JSON.stringify({ + choices: [{ + delta: {}, + finish_reason: 'tool_calls' + }] + }); + + const result = (mistral as any).parseStreamChunk(chunk2); + expect(result.isComplete).toBe(true); + expect(result.shouldContinue).toBe(true); + expect(result.toolCall).toBeDefined(); + expect(result.toolCall?.name).toBe('search_vault_files'); + expect(result.toolCall?.arguments).toEqual({ query: 'test' }); + expect(result.toolCall?.toolId).toBe('call_123'); + }); + + it('should handle invalid JSON in tool call arguments gracefully', () => { + // Start tool call with invalid JSON + const chunk1 = JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: 'call_invalid', + type: 'function', + function: { + name: 'search_vault_files', + arguments: 'invalid json {' + } + }] + }, + finish_reason: null + }] + }); + (mistral as any).parseStreamChunk(chunk1); + + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); + + // Complete with tool_calls finish reason + const chunk2 = JSON.stringify({ + choices: [{ + delta: {}, + finish_reason: 'tool_calls' + }] + }); + + const result = (mistral as any).parseStreamChunk(chunk2); + expect(result.toolCall).toBeUndefined(); + expect(exceptionSpy).toHaveBeenCalled(); + + exceptionSpy.mockRestore(); + }); + + it('should handle malformed chunk JSON', () => { + const result = (mistral as any).parseStreamChunk('invalid json {{{'); + expect(result.content).toBe(''); + expect(result.isComplete).toBe(true); + expect(result.error).toContain('Failed to parse chunk'); + }); + + it('should handle empty choices array', () => { + const chunk = JSON.stringify({ + choices: [] + }); + + const result = (mistral as any).parseStreamChunk(chunk); + expect(result.content).toBe(''); + expect(result.isComplete).toBe(false); + }); + + it('should reset accumulation state after tool call completion', () => { + // Start and complete a tool call + const chunk1 = JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: 'call_1', + type: 'function', + function: { + name: 'search_vault_files', + arguments: '{"query":"test"}' + } + }] + }, + finish_reason: 'tool_calls' + }] + }); + (mistral as any).parseStreamChunk(chunk1); + + // Accumulation state should be cleared + expect((mistral as any).accumulatedToolCalls.size).toBe(0); + }); + }); + + describe('extractContents', () => { + it('should convert simple text content to Mistral message format', async () => { + const contents = [ + new ConversationContent({ role: Role.User, content: 'Hello', displayContent: 'Hello' }), + new ConversationContent({ role: Role.Assistant, content: 'Hi there' }) + ]; + + const result = await (mistral as any).extractContents(contents); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + role: Role.User, + content: 'Hello' + }); + expect(result[1]).toEqual({ + role: Role.Assistant, + content: 'Hi there' + }); + }); + + it('should convert function call to tool_calls format', async () => { + const toolCallContent = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'abc123456', // Valid Mistral ID: 9 alphanumeric chars + name: 'search_vault_files', + args: { query: 'test' } + } + }), + timestamp: new Date(), + shouldDisplayContent: false + }); + + const result = await (mistral as any).extractContents([toolCallContent]); + + expect(result).toHaveLength(1); + expect(result[0].tool_calls).toHaveLength(1); + expect(result[0].tool_calls?.[0]).toEqual({ + id: 'abc123456', + type: 'function', + function: { + name: 'search_vault_files', + arguments: JSON.stringify({ query: 'test' }) + } + }); + }); + + it('should convert function call without ID to legacy text format', async () => { + const toolCallContent = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + name: 'search_vault_files', + args: { query: 'test' } + // No ID field + } + }), + timestamp: new Date(), + shouldDisplayContent: false + }); + + const result = await (mistral as any).extractContents([toolCallContent]); + + expect(result).toHaveLength(1); + expect(result[0].content).toContain('Historical tool call'); + expect(result[0].content).toContain('search_vault_files'); + }); + + + + it('should convert function response to tool format', async () => { + const toolCallContent = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'abc123456', // Valid Mistral ID: 9 alphanumeric chars + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'abc123456' + }); + + const responseContent = JSON.stringify({ + id: 'abc123456', + functionResponse: { + response: ['file1.txt', 'file2.txt'] + } + }); + const functionResponseContent = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'abc123456' + }); + + const result = await (mistral as any).extractContents([toolCallContent, functionResponseContent]); + + expect(result).toHaveLength(2); + expect(result[1].role).toBe('tool'); + expect(result[1].tool_call_id).toBe('abc123456'); + expect(result[1].content).toBe(JSON.stringify(['file1.txt', 'file2.txt'])); + }); + + it('should convert function response without ID to legacy text format', async () => { + const toolCallContent = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'call_legacy', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'call_legacy' + }); + + const responseContent = JSON.stringify({ + functionResponse: { + name: 'search_vault_files', + response: ['file1.txt', 'file2.txt'] + } + // No ID field + }); + const functionResponseContent = new ConversationContent({ + role: Role.User, + content: responseContent, + displayContent: responseContent, + functionResponse: responseContent, + toolId: 'call_legacy' + }); + + const result = await (mistral as any).extractContents([toolCallContent, functionResponseContent]); + + expect(result).toHaveLength(2); + expect(result[1].content).toContain('Historical tool result'); + expect(result[1].content).toContain('search_vault_files'); + }); + + it('should handle invalid JSON in function call gracefully', async () => { + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); + + const invalidContent = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: 'invalid json {', + timestamp: new Date(), + shouldDisplayContent: false + }); + + const result = await (mistral as any).extractContents([invalidContent]); + + // Should have fallback text + expect(result).toHaveLength(1); + expect(result[0].content).toBe('Error parsing function call'); + expect(exceptionSpy).toHaveBeenCalled(); + + exceptionSpy.mockRestore(); + }); + + it('should handle invalid JSON in function response gracefully', async () => { + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); + + const toolCallContent = new ConversationContent({ + role: Role.Assistant, + content: '', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'call_invalid', + name: 'search_vault_files', + args: { query: 'test' } + } + }), + toolId: 'call_invalid' + }); + + const invalidContent = new ConversationContent({ + role: Role.User, + content: 'invalid json {', + displayContent: 'invalid json {', + functionResponse: 'invalid json {', + toolId: 'call_invalid' + }); + + const result = await (mistral as any).extractContents([toolCallContent, invalidContent]); + + // Should fallback to text + expect(result).toHaveLength(2); + expect(result[1].content).toBe('invalid json {'); + expect(exceptionSpy).toHaveBeenCalled(); + + exceptionSpy.mockRestore(); + }); + + it('should filter out empty content', async () => { + const contents = [ + new ConversationContent({ role: Role.User, content: 'Hello', displayContent: 'Hello' }), + new ConversationContent({ role: Role.Assistant, content: '' }), // Empty + new ConversationContent({ role: Role.User, content: 'World', displayContent: 'World' }) + ]; + + const result = await (mistral as any).extractContents(contents); + + expect(result).toHaveLength(2); + expect(result[0].content).toBe('Hello'); + expect(result[1].content).toBe('World'); + }); + + it('should handle mixed content with text and function call', async () => { + const mixedContent = new ConversationContent({ + role: Role.Assistant, + content: 'Let me search for that', + displayContent: '', + toolCall: JSON.stringify({ + toolCall: { + id: 'def456789', // Valid Mistral ID: 9 alphanumeric chars + name: 'search_vault_files', + args: { query: 'test' } + } + }), + timestamp: new Date(), + shouldDisplayContent: false + }); + + const result = await (mistral as any).extractContents([mixedContent]); + + expect(result).toHaveLength(1); + expect(result[0].content).toBe('Let me search for that'); + expect(result[0].tool_calls).toHaveLength(1); + }); + + it('should handle attachments with images correctly', async () => { + const attachment = { + fileName: 'test-image.png', + mimeType: 'image/png', + base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + getMimeType: vi.fn(() => 'image/png'), + getFileID: vi.fn(() => 'file_123'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const content = new ConversationContent({ + role: Role.User, + content: 'Please analyze this image', + displayContent: 'Please analyze this image', + attachments: [attachment as any] + }); + + const result = await (mistral as any).extractContents([content]); + + expect(result).toHaveLength(1); + const contentParts = result[0].content as any[]; + expect(contentParts.length).toBeGreaterThan(1); + + // Should have text and image_url parts + const textPart = contentParts.find(p => p.type === 'text'); + expect(textPart).toBeDefined(); + expect(textPart.text).toBe('Please analyze this image'); + + const imagePart = contentParts.find(p => p.type === 'image_url'); + expect(imagePart).toBeDefined(); + expect(imagePart.image_url).toBe('https://signed-url.com/file_123'); + }); + + it('should handle attachments with PDFs correctly', async () => { + const attachment = { + fileName: 'document.pdf', + mimeType: 'application/pdf', + base64: 'JVBERi0xLjQKJeLjz9MK', + getMimeType: vi.fn(() => 'application/pdf'), + getFileID: vi.fn(() => 'file_456'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const content = new ConversationContent({ + role: Role.User, + content: 'Review this document', + displayContent: 'Review this document', + attachments: [attachment as any] + }); + + const result = await (mistral as any).extractContents([content]); + + expect(result).toHaveLength(1); + const contentParts = result[0].content as any[]; + expect(contentParts.length).toBeGreaterThan(1); + + // Should have text and document_url parts + const textPart = contentParts.find(p => p.type === 'text'); + expect(textPart).toBeDefined(); + + const docPart = contentParts.find(p => p.type === 'document_url'); + expect(docPart).toBeDefined(); + expect(docPart.document_url).toBe('https://signed-url.com/file_456'); + }); + + it('should handle unsupported attachment mime types', async () => { + const attachment = { + fileName: 'photo.bmp', + mimeType: 'image/bmp', + base64: 'base64bmpdata', + getMimeType: vi.fn(() => 'image/bmp'), + getFileID: vi.fn(() => 'file_bmp'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const content = new ConversationContent({ + role: Role.User, + content: 'Analyze this image', + displayContent: 'Analyze this image', + attachments: [attachment as any] + }); + + const result = await (mistral as any).extractContents([content]); + + expect(result).toHaveLength(1); + const contentParts = result[0].content as any[]; + expect(contentParts.length).toBeGreaterThan(1); + + // Should have text part with error message + const errorPart = contentParts.find(p => p.text?.includes('Unsupported mime type')); + expect(errorPart).toBeDefined(); + }); + }); + + describe('mapFunctionDefinitions', () => { + it('should map function definitions to Mistral tool format', () => { + const definitions = [ + { + name: 'search_vault_files', + description: 'Search for files', + parameters: { + type: 'object', + properties: { + query: { type: 'string' } + }, + required: ['query'] + } + }, + { + name: 'read_file', + description: 'Read a file', + parameters: { + type: 'object', + properties: { + path: { type: 'string' } + } + } + } + ]; + + const result = (mistral as any).mapFunctionDefinitions(definitions); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'search_vault_files', + description: 'Search for files', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'] + } + } + }); + expect(result[1]).toEqual({ + type: 'function', + function: { + name: 'read_file', + description: 'Read a file', + parameters: { + type: 'object', + properties: { path: { type: 'string' } }, + required: undefined + } + } + }); + }); + + it('should handle empty function definitions array', () => { + const result = (mistral as any).mapFunctionDefinitions([]); + expect(result).toEqual([]); + }); + }); + + describe('formatBinaryFiles', () => { + it('should format PDF files with document_url type', () => { + const attachment = { + fileName: 'report.pdf', + mimeType: 'application/pdf', + base64: 'base64encodedcontent', + getMimeType: vi.fn(() => 'application/pdf'), + getFileID: vi.fn(() => 'file_pdf_123'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const result = mistral.formatBinaryFiles([attachment as any]); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ + type: 'text', + text: 'Document: report.pdf' + }); + expect(parsed[1]).toEqual({ + type: 'document_url', + document_url: 'https://signed-url.com/file_pdf_123' + }); + }); + + it('should format JPEG images with image_url type', () => { + const attachment = { + fileName: 'photo.jpg', + mimeType: 'image/jpeg', + base64: 'base64imagedata', + getMimeType: vi.fn(() => 'image/jpeg'), + getFileID: vi.fn(() => 'file_img_jpeg'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const result = mistral.formatBinaryFiles([attachment as any]); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ + type: 'text', + text: 'Image: photo.jpg' + }); + expect(parsed[1]).toEqual({ + type: 'image_url', + image_url: 'https://signed-url.com/file_img_jpeg' + }); + }); + + it('should format PNG images with image_url type', () => { + const attachment = { + fileName: 'diagram.png', + mimeType: 'image/png', + base64: 'base64pngdata', + getMimeType: vi.fn(() => 'image/png'), + getFileID: vi.fn(() => 'file_img_png'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const result = mistral.formatBinaryFiles([attachment as any]); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(2); + expect(parsed[1]).toEqual({ + type: 'image_url', + image_url: 'https://signed-url.com/file_img_png' + }); + }); + + it('should handle unsupported image formats with error message', () => { + const attachment = { + fileName: 'photo.bmp', + mimeType: 'image/bmp', + base64: 'base64bmpdata', + getMimeType: vi.fn(() => 'image/bmp'), + getFileID: vi.fn(() => 'file_img_bmp'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const result = mistral.formatBinaryFiles([attachment as any]); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(1); + expect(parsed[0]).toEqual({ + type: 'text', + text: 'Unsupported mime type \'image/bmp\': photo.bmp' + }); + }); + + it('should handle multiple files of different types', () => { + const attachments = [ + { + fileName: 'doc.pdf', + mimeType: 'application/pdf', + base64: 'pdfdata', + getMimeType: vi.fn(() => 'application/pdf'), + getFileID: vi.fn(() => 'file_1'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + }, + { + fileName: 'image.jpg', + mimeType: 'image/jpeg', + base64: 'jpegdata', + getMimeType: vi.fn(() => 'image/jpeg'), + getFileID: vi.fn(() => 'file_2'), + setFileID: vi.fn(), + deleteFileID: vi.fn() + } + ]; + + const result = mistral.formatBinaryFiles(attachments as any); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(4); + + // PDF file + expect(parsed[0]).toEqual({ type: 'text', text: 'Document: doc.pdf' }); + expect(parsed[1]).toEqual({ + type: 'document_url', + document_url: 'https://signed-url.com/file_1' + }); + + // JPEG image + expect(parsed[2]).toEqual({ type: 'text', text: 'Image: image.jpg' }); + expect(parsed[3]).toEqual({ + type: 'image_url', + image_url: 'https://signed-url.com/file_2' + }); + }); + + it('should skip files without file IDs', () => { + const attachment = { + fileName: 'image.png', + mimeType: 'image/png', + base64: 'imagedata', + getMimeType: vi.fn(() => 'image/png'), + getFileID: vi.fn(() => undefined), // No file ID + setFileID: vi.fn(), + deleteFileID: vi.fn() + }; + + const result = mistral.formatBinaryFiles([attachment as any]); + const parsed = JSON.parse(result); + + // Should return empty array when no file ID is available + expect(parsed).toHaveLength(0); + }); + + it('should handle empty files array', () => { + const attachments: any[] = []; + + const result = mistral.formatBinaryFiles(attachments); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(0); + }); + }); + + describe('streamRequest', () => { + it('should call streamingService with correct parameters', async () => { + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test message' })); + + // Set system prompts before calling streamRequest + mistral.systemPrompt = 'System instruction'; + mistral.userInstruction = 'User instruction'; + mistral.aiToolDefinitions = []; + + mockStreamingService.streamRequest.mockImplementation(async function* () { + yield { content: 'response', isComplete: true }; + }); + + const generator = mistral.streamRequest(conversation); + + // Consume the generator + for await (const chunk of generator) { + // Just consume + } + + expect(mockStreamingService.streamRequest).toHaveBeenCalledWith( + expect.any(String), // URL + expect.objectContaining({ + model: 'mistral-large-latest', + max_tokens: 16384, + messages: expect.any(Array), + stream: true + }), + expect.any(Function), // parseStreamChunk + expect.objectContaining({ + 'Authorization': 'Bearer test-mistral-key', + 'Content-Type': 'application/json' + }), + expect.any(Function) // extractRetryDelay + ); + }); + + it('should reset accumulation state at start of streamRequest', async () => { + // Set some accumulated state + (mistral as any).accumulatedToolCalls.set(0, { + id: 'old_id', + name: 'old_func', + args: 'old_args' + }); + + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test' })); + + mockStreamingService.streamRequest.mockImplementation(async function* () { + yield { content: 'done', isComplete: true }; + }); + + const generator = mistral.streamRequest(conversation); + + // Start consuming + await generator.next(); + + // State should be reset + expect((mistral as any).accumulatedToolCalls.size).toBe(0); + }); + + it('should include tools in request when available', async () => { + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test' })); + + mistral.systemPrompt = 'System'; + mistral.userInstruction = 'Instruction'; + mistral.aiToolDefinitions = [ + { + name: 'search_vault_files', + description: 'Search files', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'] + } + } + ]; + + mockStreamingService.streamRequest.mockImplementation(async function* () { + yield { content: 'done', isComplete: true }; + }); + + const generator = mistral.streamRequest(conversation); + await generator.next(); + + expect(mockStreamingService.streamRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tools: expect.any(Array), + tool_choice: expect.any(String) + }), + expect.any(Function), + expect.any(Object), + expect.any(Function) + ); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/AIClasses/MistralConversationNamingService.test.ts b/__tests__/AIClasses/MistralConversationNamingService.test.ts new file mode 100644 index 0000000..18f3a4c --- /dev/null +++ b/__tests__/AIClasses/MistralConversationNamingService.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { MistralConversationNamingService } from '../../AIClasses/Mistral/MistralConversationNamingService'; +import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider'; +import { Role } from '../../Enums/Role'; + +describe('MistralConversationNamingService', () => { + let service: MistralConversationNamingService; + let mockPlugin: any; + let mockSettingsService: any; + let mockAbortService: any; + let fetchMock: any; + + beforeEach(() => { + mockPlugin = {}; + RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin); + + // Mock SettingsService + mockSettingsService = { + settings: { + model: AIProviderModel.MistralNamer, + apiKeys: { + claude: 'test-claude-key', + openai: 'test-openai-key', + gemini: 'test-gemini-key', + mistral: 'test-mistral-key' + } + }, + getApiKeyForProvider: vi.fn((provider: AIProvider) => { + if (provider === AIProvider.Claude) return 'test-claude-key'; + if (provider === AIProvider.OpenAI) return 'test-openai-key'; + if (provider === AIProvider.Gemini) return 'test-gemini-key'; + if (provider === AIProvider.Mistral) return 'test-mistral-key'; + return ''; + }), + getApiKeyForCurrentModel: vi.fn(() => 'test-mistral-key') + }; + RegisterSingleton(Services.SettingsService, mockSettingsService); + + // Mock AbortService + mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn((fn) => fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + + // Mock global fetch + fetchMock = vi.fn(); + global.fetch = fetchMock; + + service = new MistralConversationNamingService(); + }); + + afterEach(() => { + // Clear singleton registry to prevent memory leaks + DeregisterAllServices(); + vi.restoreAllMocks(); + }); + + describe('generateName', () => { + it('should make request with correct format', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: 'Test Conversation' + } + }] + }) + }); + + await service.generateName('User prompt'); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + headers: { + 'Authorization': 'Bearer test-mistral-key', + 'Content-Type': 'application/json', + }, + body: expect.any(String) + }) + ); + + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(requestBody.model).toBe(AIProviderModel.MistralNamer); + expect(requestBody.max_tokens).toBe(100); + expect(requestBody.messages).toHaveLength(2); + expect(requestBody.messages[0].role).toBe('system'); + expect(requestBody.messages[1].role).toBe(Role.User); + expect(requestBody.messages[1].content).toBe('User prompt'); + }); + + it('should return generated name from response', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: 'Generated Name' + } + }] + }) + }); + + const result = await service.generateName('Test prompt'); + + expect(result).toBe('Generated Name'); + }); + + it('should throw error on API error response', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'Invalid API key' + }); + + await expect(service.generateName('Test')) + .rejects.toThrow('Mistral API error: 401 Unauthorized - Invalid API key'); + }); + + it('should throw error when response has no content', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [] + }) + }); + + await expect(service.generateName('Test')) + .rejects.toThrow('Failed to generate conversation name'); + }); + + it('should throw error when message content is missing', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ + message: { + // No content field + } + }] + }) + }); + + await expect(service.generateName('Test')) + .rejects.toThrow('Failed to generate conversation name'); + }); + + it('should pass abort signal to fetch', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: 'Name' + } + }] + }) + }); + + await service.generateName('Test'); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + signal: expect.any(AbortSignal) + }) + ); + }); + + it('should handle malformed response structure', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + // Missing choices array + other: 'data' + }) + }); + + await expect(service.generateName('Test')) + .rejects.toThrow('Failed to generate conversation name'); + }); + + it('should trim whitespace from generated name', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: ' Generated Name ' + } + }] + }) + }); + + const result = await service.generateName('Test'); + + expect(result).toBe('Generated Name'); + }); + + it('should throw error when message content is empty string', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ + message: { + content: '' + } + }] + }) + }); + + await expect(service.generateName('Test')) + .rejects.toThrow('Failed to generate conversation name'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 7874ba4..2bdb62d 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -43,7 +43,7 @@ describe('OpenAI', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { diff --git a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts index 9ea0f05..4260d4c 100644 --- a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts +++ b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts @@ -23,7 +23,7 @@ describe('OpenAIConversationNamingService', () => { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' } }, getApiKeyForProvider: vi.fn((provider: AIProvider) => { diff --git a/__tests__/Services/SettingsService.test.ts b/__tests__/Services/SettingsService.test.ts index 9b9d17d..75023db 100644 --- a/__tests__/Services/SettingsService.test.ts +++ b/__tests__/Services/SettingsService.test.ts @@ -30,7 +30,7 @@ describe('SettingsService', () => { expect(settingsService.settings.apiKeys).toEqual({ claude: '', openai: '', - gemini: '' + gemini: '', mistral: '' }); expect(settingsService.settings.exclusions).toEqual([]); expect(settingsService.settings.userInstruction).toBe(''); @@ -46,7 +46,7 @@ describe('SettingsService', () => { apiKeys: { claude: 'claude-key-123', openai: 'openai-key-456', - gemini: 'gemini-key-789' + gemini: 'gemini-key-789', mistral: '' }, searchResultsLimit: 25, snippetSizeLimit: 200 @@ -69,7 +69,7 @@ describe('SettingsService', () => { apiKeys: { claude: '', openai: 'partial-key', - gemini: '' + gemini: '', mistral: '' } }; @@ -94,7 +94,7 @@ describe('SettingsService', () => { apiKeys: { claude: 'claude-api-key', openai: 'openai-api-key', - gemini: 'gemini-api-key' + gemini: 'gemini-api-key', mistral: '' }, exclusions: [], userInstruction: '', @@ -135,7 +135,7 @@ describe('SettingsService', () => { apiKeys: { claude: 'claude-key', openai: 'openai-key', - gemini: 'gemini-key' + gemini: 'gemini-key', mistral: '' }, exclusions: [], userInstruction: '', @@ -156,7 +156,7 @@ describe('SettingsService', () => { apiKeys: { claude: 'claude-key', openai: 'openai-key', - gemini: 'gemini-key' + gemini: 'gemini-key', mistral: '' }, exclusions: [], userInstruction: '', @@ -177,7 +177,7 @@ describe('SettingsService', () => { apiKeys: { claude: 'claude-key', openai: 'openai-key', - gemini: 'gemini-key' + gemini: 'gemini-key', mistral: '' }, exclusions: [], userInstruction: '', @@ -194,21 +194,21 @@ describe('SettingsService', () => { // Test with various Claude models settingsService = new SettingsService({ model: AIProviderModel.ClaudeOpus_4, - apiKeys: { claude: 'opus-key', openai: '', gemini: '' } + apiKeys: { claude: 'opus-key', openai: '', gemini: '', mistral: '' } }); expect(settingsService.getApiKeyForCurrentModel()).toBe('opus-key'); // Test with various Gemini models settingsService = new SettingsService({ model: AIProviderModel.GeminiPro_2_5, - apiKeys: { claude: '', openai: '', gemini: 'pro-key' } + apiKeys: { claude: '', openai: '', gemini: 'pro-key', mistral: '' } }); expect(settingsService.getApiKeyForCurrentModel()).toBe('pro-key'); // Test with various GPT models settingsService = new SettingsService({ model: AIProviderModel.GPT_5, - apiKeys: { claude: '', openai: 'gpt5-key', gemini: '' } + apiKeys: { claude: '', openai: 'gpt5-key', gemini: '', mistral: '' } }); expect(settingsService.getApiKeyForCurrentModel()).toBe('gpt5-key'); }); @@ -223,7 +223,7 @@ describe('SettingsService', () => { apiKeys: { claude: '', openai: '', - gemini: '' + gemini: '', mistral: '' }, exclusions: [], userInstruction: '', @@ -252,7 +252,7 @@ describe('SettingsService', () => { settingsService.settings.apiKeys = { claude: 'existing-claude', openai: 'existing-openai', - gemini: 'existing-gemini' + gemini: 'existing-gemini', mistral: '' }; settingsService.setApiKeyForProvider(AIProvider.Claude, 'updated-claude'); @@ -278,7 +278,7 @@ describe('SettingsService', () => { apiKeys: { claude: 'test-key', openai: '', - gemini: '' + gemini: '', mistral: '' }, exclusions: ['node_modules'], userInstruction: 'Be helpful', @@ -330,7 +330,7 @@ describe('SettingsService', () => { claudeModels.forEach(model => { settingsService = new SettingsService({ model, - apiKeys: { claude: 'test-claude', openai: '', gemini: '' } + apiKeys: { claude: 'test-claude', openai: '', gemini: '', mistral: '' } }); expect(settingsService.getApiKeyForCurrentModel()).toBe('test-claude'); @@ -347,7 +347,7 @@ describe('SettingsService', () => { geminiModels.forEach(model => { settingsService = new SettingsService({ model, - apiKeys: { claude: '', openai: '', gemini: 'test-gemini' } + apiKeys: { claude: '', openai: '', gemini: 'test-gemini', mistral: '' } }); expect(settingsService.getApiKeyForCurrentModel()).toBe('test-gemini'); @@ -365,7 +365,7 @@ describe('SettingsService', () => { openaiModels.forEach(model => { settingsService = new SettingsService({ model, - apiKeys: { claude: '', openai: 'test-openai', gemini: '' } + apiKeys: { claude: '', openai: 'test-openai', gemini: '', mistral: '' } }); expect(settingsService.getApiKeyForCurrentModel()).toBe('test-openai'); @@ -377,7 +377,7 @@ describe('SettingsService', () => { it('should maintain reference to settings object', () => { settingsService = new SettingsService({ model: AIProviderModel.ClaudeSonnet_4_5, - apiKeys: { claude: 'key', openai: '', gemini: '' } + apiKeys: { claude: 'key', openai: '', gemini: '', mistral: '' } }); const settingsRef = settingsService.settings; @@ -390,7 +390,7 @@ describe('SettingsService', () => { it('should allow direct modification of settings properties', () => { settingsService = new SettingsService({ model: AIProviderModel.ClaudeSonnet_4_5, - apiKeys: { claude: '', openai: '', gemini: '' }, + apiKeys: { claude: '', openai: '', gemini: '', mistral: '' }, exclusions: [] }); diff --git a/__tests__/Services/VaultCacheService.performance.test.ts b/__tests__/Services/VaultCacheService.performance.test.ts index cf3b0da..7d04d37 100644 --- a/__tests__/Services/VaultCacheService.performance.test.ts +++ b/__tests__/Services/VaultCacheService.performance.test.ts @@ -94,7 +94,7 @@ const mockSettings: IVaultkeeperAISettings = { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' }, exclusions: [], userInstruction: '', diff --git a/__tests__/Services/VaultService.pdf.test.ts b/__tests__/Services/VaultService.pdf.test.ts index bfc1170..9517bb5 100644 --- a/__tests__/Services/VaultService.pdf.test.ts +++ b/__tests__/Services/VaultService.pdf.test.ts @@ -54,7 +54,7 @@ const mockSettings: IVaultkeeperAISettings = { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' }, exclusions: [], userInstruction: '', diff --git a/__tests__/Services/VaultService.performance.test.ts b/__tests__/Services/VaultService.performance.test.ts index e730dc5..9a0daf6 100644 --- a/__tests__/Services/VaultService.performance.test.ts +++ b/__tests__/Services/VaultService.performance.test.ts @@ -56,7 +56,7 @@ const mockSettings: IVaultkeeperAISettings = { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' }, exclusions: [], userInstruction: '', diff --git a/__tests__/Services/VaultService.test.ts b/__tests__/Services/VaultService.test.ts index 64678d6..f7c6093 100644 --- a/__tests__/Services/VaultService.test.ts +++ b/__tests__/Services/VaultService.test.ts @@ -53,7 +53,7 @@ const mockSettings: IVaultkeeperAISettings = { apiKeys: { claude: 'test-claude-key', openai: 'test-openai-key', - gemini: 'test-gemini-key' + gemini: 'test-gemini-key', mistral: 'test-mistral-key' }, exclusions: [], userInstruction: '',