mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
f5c85fecf4
commit
8e9718ba01
15 changed files with 1785 additions and 30 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
951
__tests__/AIClasses/Mistral.test.ts
Normal file
951
__tests__/AIClasses/Mistral.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
222
__tests__/AIClasses/MistralConversationNamingService.test.ts
Normal file
222
__tests__/AIClasses/MistralConversationNamingService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
Loading…
Reference in a new issue