directus-task-management/tests/services/ai/langchain.service.test.ts

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