433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
import { LangChainService, TaskBreakdownSchema } from '../../../src/services/ai/langchain.service';
|
|
import { AIConfig } from '../../../src/config/ai.config';
|
|
import { ChatOpenAI } from '@langchain/openai';
|
|
|
|
// Mock dependencies
|
|
jest.mock('@langchain/openai');
|
|
jest.mock('langchain/memory');
|
|
|
|
describe('LangChainService', () => {
|
|
let service: LangChainService;
|
|
let mockConfig: AIConfig;
|
|
let mockLLM: jest.Mocked<ChatOpenAI>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
mockConfig = {
|
|
openai: {
|
|
apiKey: 'test-api-key',
|
|
model: 'gpt-4-turbo',
|
|
maxTokens: 4096,
|
|
temperature: 0.7,
|
|
retryAttempts: 3,
|
|
retryDelayMs: 100,
|
|
timeout: 30000,
|
|
},
|
|
langchain: {
|
|
verbose: false,
|
|
cacheEnabled: true,
|
|
cacheTTL: 3600,
|
|
maxConcurrency: 5,
|
|
memoryBufferSize: 10,
|
|
},
|
|
rateLimit: {
|
|
maxRequestsPerMinute: 60,
|
|
maxTokensPerMinute: 90000,
|
|
maxRequestsPerDay: 10000,
|
|
},
|
|
monitoring: {
|
|
trackTokenUsage: true,
|
|
logLevel: 'info',
|
|
metricsEnabled: true,
|
|
},
|
|
};
|
|
|
|
service = new LangChainService(mockConfig);
|
|
mockLLM = (ChatOpenAI as jest.MockedClass<typeof ChatOpenAI>).mock.instances[0] as jest.Mocked<ChatOpenAI>;
|
|
});
|
|
|
|
describe('createCompletionChain', () => {
|
|
it('should create a basic completion chain', () => {
|
|
const systemPrompt = 'You are a helpful assistant';
|
|
const chain = service.createCompletionChain(systemPrompt);
|
|
|
|
expect(chain).toBeDefined();
|
|
expect(chain.invoke).toBeDefined();
|
|
});
|
|
|
|
it('should create a chain with custom options', () => {
|
|
const systemPrompt = 'You are a code reviewer';
|
|
const options = {
|
|
temperature: 0.3,
|
|
maxTokens: 2000,
|
|
};
|
|
|
|
const chain = service.createCompletionChain(systemPrompt, options);
|
|
expect(chain).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('createConversationChain', () => {
|
|
it('should create a conversation chain with memory', () => {
|
|
const systemPrompt = 'You are a project assistant';
|
|
const sessionId = 'test-session-123';
|
|
|
|
const chain = service.createConversationChain(systemPrompt, sessionId);
|
|
|
|
expect(chain).toBeDefined();
|
|
expect(chain.invoke).toBeDefined();
|
|
});
|
|
|
|
it('should reuse memory for same session', () => {
|
|
const systemPrompt = 'You are a helper';
|
|
const sessionId = 'same-session';
|
|
|
|
const chain1 = service.createConversationChain(systemPrompt, sessionId);
|
|
const chain2 = service.createConversationChain(systemPrompt, sessionId);
|
|
|
|
// Both should use the same memory instance
|
|
expect(chain1).toBeDefined();
|
|
expect(chain2).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('createTaskBreakdownAgent', () => {
|
|
it('should create a task breakdown agent', () => {
|
|
const agent = service.createTaskBreakdownAgent();
|
|
|
|
expect(agent).toBeDefined();
|
|
expect(agent.invoke).toBeDefined();
|
|
});
|
|
|
|
it('should create agent with custom options', () => {
|
|
const options = {
|
|
temperature: 0.2,
|
|
maxTokens: 3000,
|
|
};
|
|
|
|
const agent = service.createTaskBreakdownAgent(options);
|
|
expect(agent).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('breakdownTask', () => {
|
|
it('should breakdown a task successfully', async () => {
|
|
const mockBreakdown = {
|
|
title: 'Implement User Authentication',
|
|
description: 'Create a secure authentication system',
|
|
subtasks: [
|
|
{
|
|
id: '1',
|
|
title: 'Setup database schema',
|
|
description: 'Create user tables',
|
|
estimatedHours: 2,
|
|
dependencies: [],
|
|
},
|
|
{
|
|
id: '2',
|
|
title: 'Implement JWT tokens',
|
|
description: 'Create JWT generation and validation',
|
|
estimatedHours: 3,
|
|
dependencies: ['1'],
|
|
},
|
|
],
|
|
estimatedTotalHours: 5,
|
|
complexity: 'major' as const,
|
|
suggestedApproach: 'Use JWT with refresh tokens',
|
|
};
|
|
|
|
// Mock the chain invoke method
|
|
const mockChain = {
|
|
invoke: jest.fn().mockResolvedValue(mockBreakdown),
|
|
};
|
|
|
|
jest.spyOn(service, 'createTaskBreakdownAgent').mockReturnValue(mockChain as any);
|
|
|
|
const result = await service.breakdownTask(
|
|
'Implement user authentication',
|
|
'Web application context',
|
|
);
|
|
|
|
expect(result).toEqual(mockBreakdown);
|
|
expect(mockChain.invoke).toHaveBeenCalledWith({
|
|
taskDescription: 'Implement user authentication',
|
|
context: 'Web application context',
|
|
});
|
|
});
|
|
|
|
it('should handle breakdown errors', async () => {
|
|
const mockChain = {
|
|
invoke: jest.fn().mockRejectedValue(new Error('AI error')),
|
|
};
|
|
|
|
jest.spyOn(service, 'createTaskBreakdownAgent').mockReturnValue(mockChain as any);
|
|
|
|
await expect(service.breakdownTask('Test task')).rejects.toThrow('Failed to breakdown task');
|
|
});
|
|
|
|
it('should validate breakdown schema', async () => {
|
|
const invalidBreakdown = {
|
|
title: 'Test Task',
|
|
// Missing required fields
|
|
};
|
|
|
|
const mockChain = {
|
|
invoke: jest.fn().mockResolvedValue(invalidBreakdown),
|
|
};
|
|
|
|
jest.spyOn(service, 'createTaskBreakdownAgent').mockReturnValue(mockChain as any);
|
|
|
|
await expect(service.breakdownTask('Test task')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('createTaskFromNaturalLanguage', () => {
|
|
it('should create task from natural language', async () => {
|
|
const mockTask = {
|
|
title: 'Fix login bug',
|
|
description: 'Users cannot login with email',
|
|
priority: 'high',
|
|
complexity: 'minor',
|
|
acceptanceCriteria: ['Login works with email', 'Error messages shown'],
|
|
tags: ['bug', 'auth'],
|
|
estimatedHours: 2,
|
|
};
|
|
|
|
const mockChain = {
|
|
invoke: jest.fn().mockResolvedValue(mockTask),
|
|
};
|
|
|
|
jest.spyOn(service, 'createTaskCreationChain').mockReturnValue(mockChain as any);
|
|
|
|
const result = await service.createTaskFromNaturalLanguage(
|
|
'Fix the bug where users cant login with their email',
|
|
'Authentication module',
|
|
);
|
|
|
|
expect(result).toMatchObject(mockTask);
|
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
describe('getTaskSuggestions', () => {
|
|
it('should generate task suggestions', async () => {
|
|
const mockSuggestions = `
|
|
1. Implement user profile page - Users need to view and edit profiles
|
|
2. Add password reset - Security requirement for production
|
|
3. Setup email notifications - Keep users informed
|
|
`;
|
|
|
|
const mockChain = {
|
|
invoke: jest.fn().mockResolvedValue(mockSuggestions),
|
|
};
|
|
|
|
jest.spyOn(service, 'createTaskSuggestionChain').mockReturnValue(mockChain as any);
|
|
|
|
const result = await service.getTaskSuggestions({
|
|
projectDescription: 'User management system',
|
|
completedTasks: ['User registration', 'Login'],
|
|
currentTasks: ['Dashboard'],
|
|
goals: ['Complete MVP', 'Launch beta'],
|
|
});
|
|
|
|
expect(result).toContain('user profile');
|
|
expect(result).toContain('password reset');
|
|
});
|
|
});
|
|
|
|
describe('memory management', () => {
|
|
it('should add messages to memory', async () => {
|
|
const sessionId = 'test-session';
|
|
|
|
// Mock the memory chatHistory
|
|
const mockMemory = {
|
|
chatHistory: {
|
|
addMessage: jest.fn(),
|
|
getMessages: jest.fn().mockResolvedValue([
|
|
{ _getType: () => 'human', content: 'Hello' },
|
|
{ _getType: () => 'ai', content: 'Hi there!' },
|
|
]),
|
|
},
|
|
clear: jest.fn(),
|
|
};
|
|
|
|
// Store mock in service's memory map for getMemorySummary to work
|
|
(service as any).memories.set(sessionId, mockMemory);
|
|
|
|
await service.addToMemory(sessionId, 'Hello', 'human');
|
|
await service.addToMemory(sessionId, 'Hi there!', 'ai');
|
|
|
|
expect(mockMemory.chatHistory.addMessage).toHaveBeenCalledTimes(2);
|
|
|
|
const summary = await service.getMemorySummary(sessionId);
|
|
expect(summary).toContain('Hello');
|
|
expect(summary).toContain('Hi there!');
|
|
});
|
|
|
|
it('should clear memory for session', async () => {
|
|
const sessionId = 'test-clear';
|
|
|
|
const mockMemory = {
|
|
chatHistory: {
|
|
addMessage: jest.fn(),
|
|
getMessages: jest.fn().mockResolvedValue([]),
|
|
},
|
|
clear: jest.fn(),
|
|
};
|
|
|
|
jest.spyOn(service as any, 'getOrCreateMemory').mockReturnValue(mockMemory);
|
|
|
|
await service.addToMemory(sessionId, 'Test message', 'human');
|
|
service.clearMemory(sessionId);
|
|
|
|
const summary = await service.getMemorySummary(sessionId);
|
|
expect(summary).toBeNull();
|
|
});
|
|
|
|
it('should clear all memories', async () => {
|
|
const mockMemory1 = {
|
|
chatHistory: {
|
|
addMessage: jest.fn(),
|
|
getMessages: jest.fn().mockResolvedValue([]),
|
|
},
|
|
clear: jest.fn(),
|
|
};
|
|
|
|
const mockMemory2 = {
|
|
chatHistory: {
|
|
addMessage: jest.fn(),
|
|
getMessages: jest.fn().mockResolvedValue([]),
|
|
},
|
|
clear: jest.fn(),
|
|
};
|
|
|
|
jest.spyOn(service as any, 'getOrCreateMemory')
|
|
.mockReturnValueOnce(mockMemory1)
|
|
.mockReturnValueOnce(mockMemory2);
|
|
|
|
await service.addToMemory('session1', 'Message 1', 'human');
|
|
await service.addToMemory('session2', 'Message 2', 'human');
|
|
|
|
service.clearAllMemories();
|
|
|
|
const summary1 = await service.getMemorySummary('session1');
|
|
const summary2 = await service.getMemorySummary('session2');
|
|
|
|
expect(summary1).toBeNull();
|
|
expect(summary2).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('streaming', () => {
|
|
it('should handle streaming responses', async () => {
|
|
const mockChain = {
|
|
stream: jest.fn().mockImplementation(async function* () {
|
|
yield 'Hello';
|
|
yield ' ';
|
|
yield 'world';
|
|
}),
|
|
};
|
|
|
|
const chunks: string[] = [];
|
|
const result = await service.streamChain(
|
|
mockChain as any,
|
|
{ input: 'test' },
|
|
(chunk) => chunks.push(chunk),
|
|
);
|
|
|
|
expect(result).toBe('Hello world');
|
|
expect(chunks).toEqual(['Hello', ' ', 'world']);
|
|
});
|
|
|
|
it('should handle streaming with objects', async () => {
|
|
const mockChain = {
|
|
stream: jest.fn().mockImplementation(async function* () {
|
|
yield { content: 'Part 1' };
|
|
yield { content: ' Part 2' };
|
|
}),
|
|
};
|
|
|
|
const chunks: string[] = [];
|
|
const result = await service.streamChain(
|
|
mockChain as any,
|
|
{ input: 'test' },
|
|
(chunk) => chunks.push(chunk),
|
|
);
|
|
|
|
expect(result).toBe('Part 1 Part 2');
|
|
expect(chunks).toEqual(['Part 1', ' Part 2']);
|
|
});
|
|
});
|
|
|
|
describe('testConnection', () => {
|
|
it('should return true when connection succeeds', async () => {
|
|
const mockChain = {
|
|
invoke: jest.fn().mockResolvedValue('OK, I can hear you!'),
|
|
};
|
|
|
|
jest.spyOn(service, 'createCompletionChain').mockReturnValue(mockChain as any);
|
|
|
|
const result = await service.testConnection();
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when connection fails', async () => {
|
|
const mockChain = {
|
|
invoke: jest.fn().mockRejectedValue(new Error('Connection failed')),
|
|
};
|
|
|
|
jest.spyOn(service, 'createCompletionChain').mockReturnValue(mockChain as any);
|
|
|
|
const result = await service.testConnection();
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('TaskBreakdownSchema validation', () => {
|
|
it('should validate correct task breakdown', () => {
|
|
const validBreakdown = {
|
|
title: 'Main Task',
|
|
description: 'Task description',
|
|
subtasks: [
|
|
{
|
|
id: '1',
|
|
title: 'Subtask 1',
|
|
description: 'Subtask description',
|
|
estimatedHours: 2,
|
|
dependencies: [],
|
|
},
|
|
],
|
|
estimatedTotalHours: 2,
|
|
complexity: 'minor' as const,
|
|
suggestedApproach: 'Step by step',
|
|
};
|
|
|
|
const result = TaskBreakdownSchema.parse(validBreakdown);
|
|
expect(result).toEqual(validBreakdown);
|
|
});
|
|
|
|
it('should reject invalid complexity', () => {
|
|
const invalidBreakdown = {
|
|
title: 'Task',
|
|
description: 'Description',
|
|
subtasks: [],
|
|
complexity: 'invalid',
|
|
};
|
|
|
|
expect(() => TaskBreakdownSchema.parse(invalidBreakdown)).toThrow();
|
|
});
|
|
|
|
it('should allow optional fields', () => {
|
|
const minimalBreakdown = {
|
|
title: 'Task',
|
|
description: 'Description',
|
|
subtasks: [],
|
|
complexity: 'trivial' as const,
|
|
};
|
|
|
|
const result = TaskBreakdownSchema.parse(minimalBreakdown);
|
|
expect(result).toEqual(minimalBreakdown);
|
|
});
|
|
});
|
|
}); |