Add comprehensive Mistral AI provider integration with cross-provider tests

Implement complete Mistral AI support including message formatting, tool calls with ID validation (9-char alphanumeric), streaming, file attachments, and conversation naming service. Add extensive cross-provider integration tests covering Mistral interactions with Claude, OpenAI, and Gemini, including round-trip conversions, ID validation, and mixed conversation handling. Update all test fixtures to include Mistral API keys.
This commit is contained in:
Andrew Beal 2026-02-28 14:29:26 +00:00
parent f5c85fecf4
commit 8e9718ba01
15 changed files with 1785 additions and 30 deletions

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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);
});
});
});
});

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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<IPrompt>(Services.IPrompt);
const plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const settingsService = Resolve<SettingsService>(Services.SettingsService);
const streaming = Resolve<StreamingService>(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)
);
});
});
});

View file

@ -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');
});
});
});

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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: []
});

View file

@ -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: '',

View file

@ -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: '',

View file

@ -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: '',

View file

@ -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: '',