Phase 3: delete OpenCodeManager + streaming, dedup MCPServer tools (-6,359 lines)
This commit is contained in:
@@ -1,679 +0,0 @@
|
||||
/**
|
||||
* OpenCodeManager Mistral Integration Tests
|
||||
*
|
||||
* Tests for Mistral AI as a first-class alternative provider:
|
||||
* - detectProvider() for Mistral model prefixes
|
||||
* - Mistral API key storage and retrieval
|
||||
* - checkReady() multi-provider support
|
||||
* - getAvailableModels() merge from both providers
|
||||
* - getProviderConfig() helper
|
||||
* - isProviderKeySet() helper
|
||||
* - Vision from catalog modalities
|
||||
* - validateMistralApiKey()
|
||||
* - Provider-aware routing in sendOpenAIMessage()
|
||||
* - generateConversationTitle() provider routing
|
||||
* - analyzeMediaImage() provider-aware routing
|
||||
* - analyzeTaxonomy() provider-aware guards
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the class
|
||||
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
getSetting = vi.fn().mockResolvedValue(null);
|
||||
setSetting = vi.fn().mockResolvedValue(undefined);
|
||||
deleteSetting = vi.fn().mockResolvedValue(undefined);
|
||||
getSelectedModel = vi.fn().mockResolvedValue('claude-sonnet-4-5');
|
||||
getDefaultSystemPrompt = vi.fn().mockResolvedValue('You are a helpful assistant.');
|
||||
getConversation = vi.fn();
|
||||
addMessage = vi.fn();
|
||||
updateConversation = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
|
||||
import type { ChatModel } from '../../src/main/shared/electronApi';
|
||||
|
||||
// Helper to create manager with mocked httpRequest
|
||||
function createManager(): OpenCodeManager {
|
||||
const manager = new OpenCodeManager(
|
||||
{
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
setSetting: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSetting: vi.fn().mockResolvedValue(undefined),
|
||||
getSelectedModel: vi.fn().mockResolvedValue('claude-sonnet-4-5'),
|
||||
getDefaultSystemPrompt: vi.fn().mockResolvedValue('You are a helpful assistant.'),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
() => null,
|
||||
);
|
||||
return manager;
|
||||
}
|
||||
|
||||
// Mock Mistral models API response
|
||||
function createMistralModelResponse(ids: string[]) {
|
||||
return {
|
||||
object: 'list',
|
||||
data: ids.map(id => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 1772132920,
|
||||
owned_by: 'mistralai',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Mock Zen models API response
|
||||
function createZenModelResponse(ids: string[]) {
|
||||
return {
|
||||
object: 'list',
|
||||
data: ids.map(id => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 1772132920,
|
||||
owned_by: 'opencode',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OpenCodeManager Mistral integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('detectProvider', () => {
|
||||
it('detects mistral model prefixes', () => {
|
||||
const manager = createManager();
|
||||
const detect = (manager as any).detectProvider.bind(manager);
|
||||
|
||||
expect(detect('mistral-large-latest')).toBe('mistral');
|
||||
expect(detect('mistral-medium-latest')).toBe('mistral');
|
||||
expect(detect('mistral-small-latest')).toBe('mistral');
|
||||
});
|
||||
|
||||
it('detects devstral model prefix', () => {
|
||||
const manager = createManager();
|
||||
const detect = (manager as any).detectProvider.bind(manager);
|
||||
|
||||
expect(detect('devstral-small-latest')).toBe('mistral');
|
||||
expect(detect('devstral-large-latest')).toBe('mistral');
|
||||
});
|
||||
|
||||
it('detects codestral model prefix', () => {
|
||||
const manager = createManager();
|
||||
const detect = (manager as any).detectProvider.bind(manager);
|
||||
|
||||
expect(detect('codestral-latest')).toBe('mistral');
|
||||
});
|
||||
|
||||
it('detects pixtral model prefix', () => {
|
||||
const manager = createManager();
|
||||
const detect = (manager as any).detectProvider.bind(manager);
|
||||
|
||||
expect(detect('pixtral-large-latest')).toBe('mistral');
|
||||
});
|
||||
|
||||
it('detects ministral model prefix', () => {
|
||||
const manager = createManager();
|
||||
const detect = (manager as any).detectProvider.bind(manager);
|
||||
|
||||
expect(detect('ministral-8b-latest')).toBe('mistral');
|
||||
});
|
||||
|
||||
it('still detects anthropic, openai, google providers', () => {
|
||||
const manager = createManager();
|
||||
const detect = (manager as any).detectProvider.bind(manager);
|
||||
|
||||
expect(detect('claude-sonnet-4')).toBe('anthropic');
|
||||
expect(detect('gpt-5')).toBe('openai');
|
||||
expect(detect('gemini-3-pro')).toBe('google');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mistral API key management', () => {
|
||||
it('stores and retrieves Mistral API key', () => {
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.getMistralApiKey()).toBe('');
|
||||
|
||||
manager.setMistralApiKey('mist-test-key-123');
|
||||
expect(manager.getMistralApiKey()).toBe('mist-test-key-123');
|
||||
});
|
||||
|
||||
it('invalidates model cache when Mistral key changes', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('opencode-key');
|
||||
|
||||
// Prime the cache
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
|
||||
});
|
||||
await manager.getAvailableModels();
|
||||
|
||||
// Set Mistral key — should clear cache
|
||||
manager.setMistralApiKey('mist-key');
|
||||
expect((manager as any).cachedModels).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkReady', () => {
|
||||
it('returns ready when only OpenCode key is set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('opencode-key');
|
||||
|
||||
const result = await manager.checkReady();
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.providers?.opencode).toBe(true);
|
||||
expect(result.providers?.mistral).toBe(false);
|
||||
});
|
||||
|
||||
it('returns ready when only Mistral key is set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mistral-key');
|
||||
|
||||
const result = await manager.checkReady();
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.providers?.opencode).toBe(false);
|
||||
expect(result.providers?.mistral).toBe(true);
|
||||
});
|
||||
|
||||
it('returns ready when both keys are set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('opencode-key');
|
||||
manager.setMistralApiKey('mistral-key');
|
||||
|
||||
const result = await manager.checkReady();
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.providers?.opencode).toBe(true);
|
||||
expect(result.providers?.mistral).toBe(true);
|
||||
});
|
||||
|
||||
it('returns not ready when no keys are set', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
const result = await manager.checkReady();
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.providers?.opencode).toBe(false);
|
||||
expect(result.providers?.mistral).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProviderKeySet', () => {
|
||||
it('checks OpenCode key availability', () => {
|
||||
const manager = createManager();
|
||||
const check = (manager as any).isProviderKeySet.bind(manager);
|
||||
|
||||
expect(check('opencode')).toBe(false);
|
||||
expect(check('anthropic')).toBe(false);
|
||||
expect(check('openai')).toBe(false);
|
||||
|
||||
manager.setApiKey('key');
|
||||
expect(check('opencode')).toBe(true);
|
||||
expect(check('anthropic')).toBe(true);
|
||||
expect(check('openai')).toBe(true);
|
||||
expect(check('google')).toBe(true);
|
||||
expect(check('other')).toBe(true);
|
||||
});
|
||||
|
||||
it('checks Mistral key availability', () => {
|
||||
const manager = createManager();
|
||||
const check = (manager as any).isProviderKeySet.bind(manager);
|
||||
|
||||
expect(check('mistral')).toBe(false);
|
||||
|
||||
manager.setMistralApiKey('key');
|
||||
expect(check('mistral')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderConfig', () => {
|
||||
it('returns OpenCode config for anthropic provider', () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('oc-key');
|
||||
const config = (manager as any).getProviderConfig.call(manager, 'anthropic');
|
||||
|
||||
expect(config.apiKey).toBe('oc-key');
|
||||
});
|
||||
|
||||
it('returns Mistral config for mistral provider', () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
const config = (manager as any).getProviderConfig.call(manager, 'mistral');
|
||||
|
||||
expect(config.apiKey).toBe('mist-key');
|
||||
expect(config.apiUrl).toContain('mistral.ai');
|
||||
expect(config.options?.parallelToolCalls).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('returns only OpenCode models when only OpenCode key is set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('oc-key');
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4', 'gpt-5'])),
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
const providers = new Set(models.map((m: ChatModel) => m.provider));
|
||||
expect(providers.has('mistral')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns only Mistral models when only Mistral key is set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('mistral.ai')) {
|
||||
return Promise.resolve({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createMistralModelResponse([
|
||||
'mistral-large-latest',
|
||||
'mistral-small-latest',
|
||||
])),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('No key'));
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
expect(models.length).toBe(2);
|
||||
expect(models.every((m: ChatModel) => m.provider === 'mistral')).toBe(true);
|
||||
});
|
||||
|
||||
it('merges models from both providers when both keys are set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('oc-key');
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
let callCount = 0;
|
||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||
callCount++;
|
||||
if (url.includes('mistral.ai')) {
|
||||
return Promise.resolve({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createMistralModelResponse([
|
||||
'mistral-large-latest',
|
||||
'mistral-small-latest',
|
||||
])),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4', 'gpt-5'])),
|
||||
});
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
expect(models.length).toBe(4);
|
||||
|
||||
const providers = new Set(models.map((m: ChatModel) => m.provider));
|
||||
expect(providers.has('anthropic')).toBe(true);
|
||||
expect(providers.has('mistral')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes vision field from catalog modalities', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
// Mock catalog with modality data for vision resolution
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('mistral.ai')) {
|
||||
return Promise.resolve({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createMistralModelResponse([
|
||||
'mistral-large-latest',
|
||||
'devstral-small-latest',
|
||||
])),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('No key'));
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
const large = models.find((m: ChatModel) => m.id === 'mistral-large-latest');
|
||||
const devstral = models.find((m: ChatModel) => m.id === 'devstral-small-latest');
|
||||
|
||||
expect(large?.vision).toBe(true);
|
||||
expect(devstral?.vision).toBe(false);
|
||||
});
|
||||
|
||||
it('fallback model list filters by available provider keys', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
// No OpenCode key set
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
// Should only have Mistral models from fallback
|
||||
const providers = new Set(models.map((m: ChatModel) => m.provider));
|
||||
expect(providers.has('mistral')).toBe(true);
|
||||
expect(providers.has('anthropic')).toBe(false);
|
||||
expect(providers.has('openai')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMistralApiKey', () => {
|
||||
it('validates a correct Mistral API key', async () => {
|
||||
const manager = createManager();
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createMistralModelResponse(['mistral-large-latest'])),
|
||||
});
|
||||
|
||||
const result = await manager.validateMistralApiKey('valid-key');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.models.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects an invalid Mistral API key', async () => {
|
||||
const manager = createManager();
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 401,
|
||||
body: '{"message":"Unauthorized"}',
|
||||
});
|
||||
|
||||
const result = await manager.validateMistralApiKey('bad-key');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.models).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects empty key', async () => {
|
||||
const manager = createManager();
|
||||
const result = await manager.validateMistralApiKey('');
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConversationTitle provider routing', () => {
|
||||
it('uses Mistral API when conversation model is a Mistral model', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
const httpMock = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
choices: [{ message: { content: 'Travel Blog' } }],
|
||||
}),
|
||||
});
|
||||
(manager as any).httpRequest = httpMock;
|
||||
|
||||
// Set the title model to mistral
|
||||
(manager as any).chatEngine.getSetting = vi.fn().mockImplementation(async (key: string) => {
|
||||
if (key === 'chat_title_model') return 'mistral-small-latest';
|
||||
return null;
|
||||
});
|
||||
|
||||
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
|
||||
|
||||
expect(httpMock).toHaveBeenCalled();
|
||||
const callUrl = httpMock.mock.calls[0][0];
|
||||
expect(callUrl).toContain('mistral.ai');
|
||||
});
|
||||
|
||||
it('uses Anthropic API when title model is an Anthropic model', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('oc-key');
|
||||
|
||||
const httpMock = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
content: [{ type: 'text', text: 'Travel Blog' }],
|
||||
}),
|
||||
});
|
||||
(manager as any).httpRequest = httpMock;
|
||||
|
||||
// No title model set — defaults to claude-haiku-4-5
|
||||
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
|
||||
|
||||
expect(httpMock).toHaveBeenCalled();
|
||||
const callUrl = httpMock.mock.calls[0][0];
|
||||
expect(callUrl).toContain('opencode.ai');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeTaxonomy provider-aware guards', () => {
|
||||
it('returns error when model is Mistral but no Mistral key is set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('oc-key'); // only OpenCode key
|
||||
|
||||
const result = await manager.analyzeTaxonomy(
|
||||
[{ name: 'Travel', slug: 'travel', existsInProject: true }],
|
||||
[],
|
||||
'mistral-large-latest'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API key');
|
||||
});
|
||||
|
||||
it('returns error when model is OpenCode but no OpenCode key is set', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key'); // only Mistral key
|
||||
|
||||
const result = await manager.analyzeTaxonomy(
|
||||
[{ name: 'Travel', slug: 'travel', existsInProject: true }],
|
||||
[],
|
||||
'claude-sonnet-4'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeMediaImage provider-aware routing', () => {
|
||||
it('returns error when no API key is available for the configured model', async () => {
|
||||
const manager = createManager();
|
||||
// No keys set at all
|
||||
|
||||
const result = await manager.analyzeMediaImage('media-1', 'en');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setApiKey cache invalidation', () => {
|
||||
it('invalidates model cache when OpenCode key changes', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
// Prime the cache
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createMistralModelResponse(['mistral-large-latest'])),
|
||||
});
|
||||
await manager.getAvailableModels();
|
||||
expect((manager as any).cachedModels).not.toBeNull();
|
||||
|
||||
// Set OpenCode key — should clear cache
|
||||
manager.setApiKey('oc-key');
|
||||
expect((manager as any).cachedModels).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('vision from catalog modalities', () => {
|
||||
it('vision flags are derived from catalog input modalities via getAvailableModels', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
|
||||
// Mock catalog with modality data
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'mistral-medium-latest', name: 'Mistral Medium', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ id: 'devstral-large-latest', name: 'Devstral Large', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('mistral.ai')) {
|
||||
return Promise.resolve({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createMistralModelResponse([
|
||||
'mistral-large-latest',
|
||||
'mistral-medium-latest',
|
||||
'mistral-small-latest',
|
||||
'devstral-small-latest',
|
||||
'devstral-large-latest',
|
||||
])),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('No key'));
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
// Vision-capable models (image in input modalities)
|
||||
expect(models.find((m: ChatModel) => m.id === 'mistral-large-latest')?.vision).toBe(true);
|
||||
expect(models.find((m: ChatModel) => m.id === 'mistral-medium-latest')?.vision).toBe(true);
|
||||
expect(models.find((m: ChatModel) => m.id === 'mistral-small-latest')?.vision).toBe(true);
|
||||
|
||||
// Non-vision models (no image in input modalities)
|
||||
expect(models.find((m: ChatModel) => m.id === 'devstral-small-latest')?.vision).toBe(false);
|
||||
expect(models.find((m: ChatModel) => m.id === 'devstral-large-latest')?.vision).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConversationTitle smart defaults', () => {
|
||||
it('falls back to mistral-small-latest when only Mistral key is set and no title model configured', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
// No OpenCode key set
|
||||
|
||||
const httpMock = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
choices: [{ message: { content: 'Blog Post' } }],
|
||||
}),
|
||||
});
|
||||
(manager as any).httpRequest = httpMock;
|
||||
|
||||
// No title model configured (returns null)
|
||||
(manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null);
|
||||
(manager as any).chatEngine.updateConversation = vi.fn();
|
||||
|
||||
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
|
||||
|
||||
expect(httpMock).toHaveBeenCalled();
|
||||
const callUrl = httpMock.mock.calls[0][0];
|
||||
expect(callUrl).toContain('mistral.ai');
|
||||
// Verify it used mistral-small-latest
|
||||
const body = JSON.parse(httpMock.mock.calls[0][1].body);
|
||||
expect(body.model).toBe('mistral-small-latest');
|
||||
});
|
||||
|
||||
it('does not generate title when no keys are set', async () => {
|
||||
const manager = createManager();
|
||||
// No keys at all
|
||||
|
||||
const httpMock = vi.fn();
|
||||
(manager as any).httpRequest = httpMock;
|
||||
(manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null);
|
||||
|
||||
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
|
||||
|
||||
expect(httpMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeMediaImage smart defaults', () => {
|
||||
it('falls back to mistral-large-latest when only Mistral key is set and no image model configured', async () => {
|
||||
const manager = createManager();
|
||||
manager.setMistralApiKey('mist-key');
|
||||
// No OpenCode key set
|
||||
|
||||
// Mock getSetting to return null (no configured model)
|
||||
(manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null);
|
||||
|
||||
// Mock mediaEngine — return a valid image
|
||||
(manager as any).mediaEngine = {
|
||||
getMedia: vi.fn().mockResolvedValue({ id: 'media-1', mimeType: 'image/jpeg' }),
|
||||
getThumbnailDataUrl: vi.fn().mockResolvedValue('data:image/webp;base64,dGVzdA=='),
|
||||
};
|
||||
|
||||
const httpMock = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({ title: 'Sunset', alt: 'A sunset', caption: 'Beautiful sunset' }),
|
||||
},
|
||||
}],
|
||||
}),
|
||||
});
|
||||
(manager as any).httpRequest = httpMock;
|
||||
|
||||
await manager.analyzeMediaImage('media-1', 'en');
|
||||
|
||||
expect(httpMock).toHaveBeenCalled();
|
||||
const callUrl = httpMock.mock.calls[0][0];
|
||||
expect(callUrl).toContain('mistral.ai');
|
||||
const body = JSON.parse(httpMock.mock.calls[0][1].body);
|
||||
expect(body.model).toBe('mistral-large-latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey returns models from API response', () => {
|
||||
it('returns models from the actual API response', async () => {
|
||||
const manager = createManager();
|
||||
manager.setApiKey('oc-key');
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
|
||||
});
|
||||
|
||||
const result = await manager.validateApiKey('oc-key');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.models).toHaveLength(1);
|
||||
expect(result.models[0].id).toBe('claude-sonnet-4');
|
||||
expect(result.models[0].provider).toBe('anthropic');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* OpenCodeManager Tool Execution Tests
|
||||
*
|
||||
* Tests the executeTool method for post-related tools,
|
||||
* specifically that backlinks and linksTo are included in results.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the class
|
||||
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
getSetting = vi.fn();
|
||||
setSetting = vi.fn();
|
||||
getSelectedModel = vi.fn();
|
||||
getDefaultSystemPrompt = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
|
||||
|
||||
function createMockPostEngine() {
|
||||
return {
|
||||
getPost: vi.fn(),
|
||||
searchPosts: vi.fn(),
|
||||
searchPostsFiltered: vi.fn(),
|
||||
getAllPosts: vi.fn(),
|
||||
getPostsFiltered: vi.fn(),
|
||||
getDashboardStats: vi.fn().mockResolvedValue({ totalPosts: 0 }),
|
||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
|
||||
getBlogStats: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMediaEngine() {
|
||||
return {
|
||||
getAllMedia: vi.fn(),
|
||||
getMedia: vi.fn(),
|
||||
getThumbnailDataUrl: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPostMediaEngine() {
|
||||
return {
|
||||
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
|
||||
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
function createManager(postEngine: ReturnType<typeof createMockPostEngine>, mediaEngine?: ReturnType<typeof createMockMediaEngine>, postMediaEngine?: ReturnType<typeof createMockPostMediaEngine>) {
|
||||
const manager = new OpenCodeManager(
|
||||
{ getSetting: vi.fn(), setSetting: vi.fn() } as never,
|
||||
postEngine as never,
|
||||
(mediaEngine ?? createMockMediaEngine()) as never,
|
||||
(postMediaEngine ?? createMockPostMediaEngine()) as never,
|
||||
() => null,
|
||||
);
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe('OpenCodeManager tool execution – backlinks & linksTo', () => {
|
||||
let mockPostEngine: ReturnType<typeof createMockPostEngine>;
|
||||
let manager: OpenCodeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPostEngine = createMockPostEngine();
|
||||
manager = createManager(mockPostEngine);
|
||||
});
|
||||
|
||||
describe('read_post', () => {
|
||||
it('includes backlinks and linksTo in the response', async () => {
|
||||
const post = {
|
||||
id: 'p1', title: 'Target Post', slug: 'target-post',
|
||||
content: '# Hello', excerpt: 'Hello', status: 'published',
|
||||
author: 'Test', categories: ['article'], tags: ['test'],
|
||||
createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
|
||||
publishedAt: new Date('2025-01-01'),
|
||||
};
|
||||
mockPostEngine.getPost.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([
|
||||
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
|
||||
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
|
||||
]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([
|
||||
{ id: 'p4', title: 'Linked Target', slug: 'linked-target' },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.post.backlinks).toEqual([
|
||||
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
|
||||
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
|
||||
]);
|
||||
expect(result.post.linksTo).toEqual([
|
||||
{ id: 'p4', title: 'Linked Target', slug: 'linked-target' },
|
||||
]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('returns empty backlinks and linksTo arrays when none exist', async () => {
|
||||
const post = {
|
||||
id: 'p1', title: 'Lonely Post', slug: 'lonely-post',
|
||||
content: '# Alone', excerpt: '', status: 'draft',
|
||||
categories: [], tags: [],
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
mockPostEngine.getPost.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
|
||||
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.post.backlinks).toEqual([]);
|
||||
expect(result.post.linksTo).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_posts', () => {
|
||||
it('includes backlinks and linksTo for each post in search results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p1', title: 'Post One', slug: 'post-one', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'p2', title: 'Post Two', slug: 'post-two', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue(posts);
|
||||
mockPostEngine.getLinkedBy
|
||||
.mockResolvedValueOnce([{ id: 'p3', title: 'Linker', slug: 'linker' }])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockPostEngine.getLinksTo
|
||||
.mockResolvedValueOnce([{ id: 'p4', title: 'Target', slug: 'target' }])
|
||||
.mockResolvedValueOnce([{ id: 'p5', title: 'Other', slug: 'other' }]);
|
||||
|
||||
const result = await (manager as any).executeTool('search_posts', { query: 'test' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([{ id: 'p3', title: 'Linker', slug: 'linker' }]);
|
||||
expect(result.posts[0].linksTo).toEqual([{ id: 'p4', title: 'Target', slug: 'target' }]);
|
||||
expect(result.posts[1].backlinks).toEqual([]);
|
||||
expect(result.posts[1].linksTo).toEqual([{ id: 'p5', title: 'Other', slug: 'other' }]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledTimes(2);
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_posts', () => {
|
||||
it('includes backlinks and linksTo for each post in listed results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p1', title: 'Post A', slug: 'post-a', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.getAllPosts.mockResolvedValue({ items: posts, total: 1 });
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([
|
||||
{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' },
|
||||
]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([
|
||||
{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' }]);
|
||||
expect(result.posts[0].linksTo).toEqual([{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' }]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('includes backlinks and linksTo for filtered list results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p5', title: 'Tagged Post', slug: 'tagged', status: 'published', categories: [], tags: ['js'], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue(posts);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', { tags: ['js'] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([]);
|
||||
expect(result.posts[0].linksTo).toEqual([]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p5');
|
||||
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p5');
|
||||
});
|
||||
});
|
||||
|
||||
// ── check_term tool ──────────────────────────────────────────────
|
||||
|
||||
describe('check_term', () => {
|
||||
it('returns category and tag info for a term that exists as both', async () => {
|
||||
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
|
||||
{ category: 'wiki', count: 3 },
|
||||
{ category: 'tech', count: 5 },
|
||||
]);
|
||||
mockPostEngine.getTagsWithCounts.mockResolvedValue([
|
||||
{ tag: 'wiki', count: 1 },
|
||||
{ tag: 'python', count: 4 },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('check_term', { term: 'wiki' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.term).toBe('wiki');
|
||||
expect(result.asCategory).toBe(true);
|
||||
expect(result.categoryPostCount).toBe(3);
|
||||
expect(result.asTag).toBe(true);
|
||||
expect(result.tagPostCount).toBe(1);
|
||||
});
|
||||
|
||||
it('returns false for a term that does not exist', async () => {
|
||||
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
|
||||
{ category: 'tech', count: 5 },
|
||||
]);
|
||||
mockPostEngine.getTagsWithCounts.mockResolvedValue([
|
||||
{ tag: 'python', count: 4 },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('check_term', { term: 'nonexistent' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.term).toBe('nonexistent');
|
||||
expect(result.asCategory).toBe(false);
|
||||
expect(result.categoryPostCount).toBe(0);
|
||||
expect(result.asTag).toBe(false);
|
||||
expect(result.tagPostCount).toBe(0);
|
||||
});
|
||||
|
||||
it('is case-insensitive', async () => {
|
||||
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
|
||||
{ category: 'Wiki', count: 3 },
|
||||
]);
|
||||
mockPostEngine.getTagsWithCounts.mockResolvedValue([]);
|
||||
|
||||
const result = await (manager as any).executeTool('check_term', { term: 'wiki' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.asCategory).toBe(true);
|
||||
expect(result.categoryPostCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── month validation ────────────────────────────────────────────────
|
||||
|
||||
describe('month validation', () => {
|
||||
it('search_posts returns error when month is given without year', async () => {
|
||||
const result = await (manager as any).executeTool('search_posts', { query: 'test', month: 3 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('month');
|
||||
expect(result.error).toContain('year');
|
||||
});
|
||||
|
||||
it('list_posts returns error when month is given without year', async () => {
|
||||
const result = await (manager as any).executeTool('list_posts', { month: 3 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('month');
|
||||
expect(result.error).toContain('year');
|
||||
});
|
||||
|
||||
it('list_media returns error when month is given without year', async () => {
|
||||
const result = await (manager as any).executeTool('list_media', { month: 3 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('month');
|
||||
expect(result.error).toContain('year');
|
||||
});
|
||||
|
||||
it('search_posts accepts month when year is also given', async () => {
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
|
||||
const result = await (manager as any).executeTool('search_posts', { query: 'test', year: 2025, month: 3 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('list_posts accepts month when year is also given', async () => {
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
|
||||
const result = await (manager as any).executeTool('list_posts', { year: 2025, month: 3 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ambiguity hints ─────────────────────────────────────────────────
|
||||
|
||||
describe('ambiguity hints', () => {
|
||||
it('search_posts includes hint when category also exists as tag', async () => {
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue([
|
||||
{ id: 'p1', title: 'Post', slug: 'post', excerpt: '', status: 'published', categories: ['wiki'], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
]);
|
||||
mockPostEngine.getTagsWithCounts.mockResolvedValue([
|
||||
{ tag: 'wiki', count: 2 },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('search_posts', { query: 'test', category: 'wiki' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.hints).toBeDefined();
|
||||
expect(result.hints.length).toBeGreaterThan(0);
|
||||
expect(result.hints[0]).toContain('wiki');
|
||||
expect(result.hints[0]).toContain('tag');
|
||||
});
|
||||
|
||||
it('list_posts includes hint when category also exists as tag', async () => {
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue([
|
||||
{ id: 'p1', title: 'Post', slug: 'post', status: 'published', categories: ['wiki'], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
]);
|
||||
mockPostEngine.getTagsWithCounts.mockResolvedValue([
|
||||
{ tag: 'wiki', count: 2 },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', { category: 'wiki' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.hints).toBeDefined();
|
||||
expect(result.hints[0]).toContain('wiki');
|
||||
expect(result.hints[0]).toContain('tag');
|
||||
});
|
||||
|
||||
it('list_posts includes hint when tags also exist as categories', async () => {
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
|
||||
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
|
||||
{ category: 'wiki', count: 3 },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', { tags: ['wiki'] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.hints).toBeDefined();
|
||||
expect(result.hints[0]).toContain('wiki');
|
||||
expect(result.hints[0]).toContain('category');
|
||||
});
|
||||
|
||||
it('search_posts does not include hints when no ambiguity exists', async () => {
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
|
||||
mockPostEngine.getTagsWithCounts.mockResolvedValue([
|
||||
{ tag: 'python', count: 4 },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('search_posts', { query: 'test', category: 'tech' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.hints).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── check_term tool definition ──────────────────────────────────────
|
||||
|
||||
describe('OpenCodeManager tool definitions', () => {
|
||||
let manager: OpenCodeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager = createManager(createMockPostEngine());
|
||||
});
|
||||
|
||||
it('includes check_term in tool definitions', () => {
|
||||
const tools = (manager as any).getToolDefinitions();
|
||||
const checkTerm = tools.find((t: any) => t.name === 'check_term');
|
||||
expect(checkTerm).toBeDefined();
|
||||
expect(checkTerm.input_schema.required).toContain('term');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCodeManager – getMaxOutputTokens (ModelCatalogEngine delegate)', () => {
|
||||
let manager: OpenCodeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager = createManager(createMockPostEngine());
|
||||
});
|
||||
|
||||
it('delegates to ModelCatalogEngine.getMaxOutputTokens', async () => {
|
||||
const engine = (manager as any).modelCatalogEngine;
|
||||
vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(64000);
|
||||
|
||||
const result = await (manager as any).getMaxOutputTokens('claude-sonnet-4-5');
|
||||
expect(result).toBe(64000);
|
||||
expect(engine.getMaxOutputTokens).toHaveBeenCalledWith('claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
it('returns default when ModelCatalogEngine has no data', async () => {
|
||||
const engine = (manager as any).modelCatalogEngine;
|
||||
vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(16384);
|
||||
|
||||
const result = await (manager as any).getMaxOutputTokens('unknown-model');
|
||||
expect(result).toBe(16384);
|
||||
});
|
||||
|
||||
it('exposes ModelCatalogEngine via getModelCatalogEngine()', () => {
|
||||
const engine = manager.getModelCatalogEngine();
|
||||
expect(engine).toBeDefined();
|
||||
expect(engine).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* OpenCodeManager Model Discovery Tests
|
||||
*
|
||||
* Tests the model discovery, display name formatting, and caching behavior.
|
||||
* Following TDD: these tests describe the expected behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the class
|
||||
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
getSetting = vi.fn();
|
||||
setSetting = vi.fn();
|
||||
getSelectedModel = vi.fn();
|
||||
getDefaultSystemPrompt = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
|
||||
import type { ChatModel } from '../../src/main/shared/electronApi';
|
||||
|
||||
// Helper to create manager with mocked httpRequest
|
||||
function createManager(): OpenCodeManager {
|
||||
const manager = new OpenCodeManager(
|
||||
{ getSetting: vi.fn(), setSetting: vi.fn() } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
() => null,
|
||||
);
|
||||
manager.setApiKey('test-key');
|
||||
return manager;
|
||||
}
|
||||
|
||||
// Mock API response in the Zen format (id, object, created, owned_by — no name field)
|
||||
function createZenModelResponse(ids: string[]) {
|
||||
return {
|
||||
object: 'list',
|
||||
data: ids.map(id => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 1772132920,
|
||||
owned_by: 'opencode',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OpenCodeManager model discovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('returns models from API with catalog names and catalog-derived vision', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
// Mock catalog with modality data and display names
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image', 'pdf'], outputModalities: ['text'] },
|
||||
{ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', inputModalities: ['text'], outputModalities: ['text'] },
|
||||
{ id: 'gemini-3-pro', name: 'Gemini 3 Pro', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const zenResponse = createZenModelResponse([
|
||||
'claude-sonnet-4',
|
||||
'gpt-5.1-codex',
|
||||
'gemini-3-pro',
|
||||
]);
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(zenResponse),
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic', vision: true });
|
||||
expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai', vision: false });
|
||||
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true });
|
||||
});
|
||||
|
||||
it('falls back to model catalog when API fails', async () => {
|
||||
const manager = createManager();
|
||||
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'gpt-5', name: 'GPT 5', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
const ids = models.map((m: ChatModel) => m.id);
|
||||
expect(ids).toContain('claude-sonnet-4');
|
||||
expect(ids).toContain('gpt-5');
|
||||
const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4');
|
||||
expect(claudeModel?.provider).toBe('anthropic');
|
||||
expect(claudeModel?.name).toBe('Claude Sonnet 4');
|
||||
const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5');
|
||||
expect(gptModel?.provider).toBe('openai');
|
||||
expect(gptModel?.name).toBe('GPT 5');
|
||||
});
|
||||
|
||||
it('falls back to model catalog when API returns non-200 status', async () => {
|
||||
const manager = createManager();
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 401,
|
||||
body: '{"error":"unauthorized"}',
|
||||
});
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
const ids = models.map((m: ChatModel) => m.id);
|
||||
expect(ids).toContain('claude-sonnet-4');
|
||||
});
|
||||
|
||||
it('caches models and does not re-fetch within TTL', async () => {
|
||||
const manager = createManager();
|
||||
const httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
|
||||
});
|
||||
(manager as any).httpRequest = httpRequest;
|
||||
|
||||
await manager.getAvailableModels();
|
||||
await manager.getAvailableModels();
|
||||
|
||||
expect(httpRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-fetches after cache TTL expires', async () => {
|
||||
const manager = createManager();
|
||||
const httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
|
||||
});
|
||||
(manager as any).httpRequest = httpRequest;
|
||||
|
||||
await manager.getAvailableModels();
|
||||
expect(httpRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past 5-minute TTL
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
|
||||
await manager.getAvailableModels();
|
||||
expect(httpRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles unknown model IDs from API with raw IDs as fallback names', async () => {
|
||||
const manager = createManager();
|
||||
const zenResponse = createZenModelResponse(['some-new-model-v3']);
|
||||
|
||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(zenResponse),
|
||||
});
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0].name).toBe('some-new-model-v3');
|
||||
expect(models[0].provider).toBe('other');
|
||||
});
|
||||
|
||||
it('falls back to model catalog when no API key is set', async () => {
|
||||
const manager = createManager();
|
||||
(manager as any).apiKey = '';
|
||||
manager.setMistralApiKey('test-key');
|
||||
(manager as any).modelCatalogEngine = {
|
||||
getAll: vi.fn().mockResolvedValue([
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
|
||||
]),
|
||||
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
|
||||
getContextWindow: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const models = await manager.getAvailableModels();
|
||||
|
||||
// Only Mistral models will be in fallback since only Mistral key is set
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
const providers = new Set(models.map((m: ChatModel) => m.provider));
|
||||
expect(providers.has('mistral')).toBe(true);
|
||||
// OpenCode/Anthropic models should be filtered out (no OpenCode key)
|
||||
expect(providers.has('anthropic')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user