diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index b2d5b54..f0f85de 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -3,7 +3,7 @@ import { Claude } from '../../AIClasses/Claude/Claude'; import { RegisterSingleton, Resolve, DeregisterAllServices } from '../../Services/DependencyService'; import { Services } from '../../Services/Services'; import { StreamingService } from '../../Services/StreamingService'; -import type { IPrompt } from '../../AIClasses/IPrompt'; +import type { IPrompt } from '../../AIPrompts/IPrompt'; import type VaultkeeperAIPlugin from '../../main'; import { AIFunctionDefinitions } from '../../AIClasses/FunctionDefinitions/AIFunctionDefinitions'; import { Conversation } from '../../Conversations/Conversation'; @@ -65,7 +65,6 @@ describe('Claude', () => { }; RegisterSingleton(Services.StreamingService, mockStreamingService); - // Mock AIFunctionDefinitions mockFunctionDefinitions = { getQueryActions: vi.fn().mockReturnValue([ { @@ -80,7 +79,6 @@ describe('Claude', () => { } ]) }; - RegisterSingleton(Services.AIFunctionDefinitions, mockFunctionDefinitions); // Mock IAIFileService const mockFileService = { @@ -115,13 +113,12 @@ describe('Claude', () => { const plugin = Resolve(Services.VaultkeeperAIPlugin); const settingsService = Resolve(Services.SettingsService); const streaming = Resolve(Services.StreamingService); - const functions = Resolve(Services.AIFunctionDefinitions); expect(prompt).toBe(mockPrompt); expect(plugin).toBe(mockPlugin); expect(settingsService).toBe(mockSettingsService); expect(streaming).toBe(mockStreamingService); - expect(functions).toBe(mockFunctionDefinitions); + expect(AIFunctionDefinitions.agentDefinitions).toBeDefined(); }); }); @@ -1097,11 +1094,16 @@ describe('Claude', () => { const conversation = new Conversation(); conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test message' })); + // Set system prompts before calling streamRequest + claude.systemPrompt = 'System instruction'; + claude.userInstruction = 'User instruction'; + claude.toolDefinitions = []; + mockStreamingService.streamRequest.mockImplementation(async function* () { yield { content: 'response', isComplete: true }; }); - const generator = claude.streamRequest(conversation, true); + const generator = claude.streamRequest(conversation); // Consume the generator for await (const chunk of generator) { @@ -1146,7 +1148,7 @@ describe('Claude', () => { yield { content: 'done', isComplete: true }; }); - const generator = claude.streamRequest(conversation, false); + const generator = claude.streamRequest(conversation); // Start consuming await generator.next(); diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index bc0800c..faff4b8 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -3,9 +3,8 @@ import { Gemini } from '../../AIClasses/Gemini/Gemini'; import { RegisterSingleton, Resolve, DeregisterAllServices } from '../../Services/DependencyService'; import { Services } from '../../Services/Services'; import { StreamingService } from '../../Services/StreamingService'; -import type { IPrompt } from '../../AIClasses/IPrompt'; +import type { IPrompt } from '../../AIPrompts/IPrompt'; import type VaultkeeperAIPlugin from '../../main'; -import { AIFunctionDefinitions } from '../../AIClasses/FunctionDefinitions/AIFunctionDefinitions'; import { Conversation } from '../../Conversations/Conversation'; import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; @@ -20,7 +19,6 @@ describe('Gemini', () => { let mockPrompt: any; let mockPlugin: any; let mockSettingsService: any; - let mockFunctionDefinitions: any; let abortService: AbortService; beforeEach(() => { @@ -65,23 +63,6 @@ describe('Gemini', () => { }; RegisterSingleton(Services.StreamingService, mockStreamingService); - // Mock AIFunctionDefinitions - mockFunctionDefinitions = { - getQueryActions: vi.fn().mockReturnValue([ - { - name: 'search_vault_filestion', - description: 'Test function', - parameters: { - type: 'object', - properties: { - query: { type: 'string' } - } - } - } - ]) - }; - RegisterSingleton(Services.AIFunctionDefinitions, mockFunctionDefinitions); - // Mock IAIFileService const mockFileService = { refreshCache: vi.fn().mockResolvedValue(undefined), @@ -114,13 +95,11 @@ describe('Gemini', () => { const plugin = Resolve(Services.VaultkeeperAIPlugin); const settingsService = Resolve(Services.SettingsService); const streaming = Resolve(Services.StreamingService); - const functions = Resolve(Services.AIFunctionDefinitions); expect(prompt).toBe(mockPrompt); expect(plugin).toBe(mockPlugin); expect(settingsService).toBe(mockSettingsService); expect(streaming).toBe(mockStreamingService); - expect(functions).toBe(mockFunctionDefinitions); }); }); @@ -365,7 +344,7 @@ describe('Gemini', () => { yield { content: 'done', isComplete: true }; }); - const generator = gemini.streamRequest(conversation, true); + const generator = gemini.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -393,7 +372,7 @@ describe('Gemini', () => { yield { content: 'done', isComplete: true }; }); - const generator = gemini.streamRequest(conversation, true); + const generator = gemini.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -414,7 +393,7 @@ describe('Gemini', () => { yield { content: 'done', isComplete: true }; }); - const generator = gemini.streamRequest(conversation, true); + const generator = gemini.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -428,11 +407,16 @@ describe('Gemini', () => { const conversation = new Conversation(); conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test', displayContent: 'Test' })); + // Set system prompts before calling streamRequest + gemini.systemPrompt = 'System instruction'; + gemini.userInstruction = 'User instruction'; + gemini.toolDefinitions = []; + mockStreamingService.streamRequest.mockImplementation(async function* () { yield { content: 'done', isComplete: true }; }); - const generator = gemini.streamRequest(conversation, true); + const generator = gemini.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -1032,7 +1016,7 @@ describe('Gemini', () => { yield { content: 'done', isComplete: true }; }); - const generator = gemini.streamRequest(conversation, true); + const generator = gemini.streamRequest(conversation); for await (const chunk of generator) {} @@ -1063,7 +1047,7 @@ describe('Gemini', () => { yield { content: 'done', isComplete: true }; }); - const generator = gemini.streamRequest(conversation, false); + const generator = gemini.streamRequest(conversation); await generator.next(); // State should be reset (after checking web search mode) diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index fc4b16f..f22584c 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -3,9 +3,8 @@ import { OpenAI } from '../../AIClasses/OpenAI/OpenAI'; import { RegisterSingleton, Resolve, DeregisterAllServices } from '../../Services/DependencyService'; import { Services } from '../../Services/Services'; import { StreamingService } from '../../Services/StreamingService'; -import type { IPrompt } from '../../AIClasses/IPrompt'; +import type { IPrompt } from '../../AIPrompts/IPrompt'; import type VaultkeeperAIPlugin from '../../main'; -import { AIFunctionDefinitions } from '../../AIClasses/FunctionDefinitions/AIFunctionDefinitions'; import { Conversation } from '../../Conversations/Conversation'; import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; @@ -20,7 +19,6 @@ describe('OpenAI', () => { let mockPrompt: any; let mockPlugin: any; let mockSettingsService: any; - let mockFunctionDefinitions: any; let abortService: AbortService; beforeEach(() => { @@ -68,23 +66,6 @@ describe('OpenAI', () => { }; RegisterSingleton(Services.StreamingService, mockStreamingService); - // Mock AIFunctionDefinitions - mockFunctionDefinitions = { - getQueryActions: vi.fn().mockReturnValue([ - { - name: 'search_vault_filestion', - description: 'Test function', - parameters: { - type: 'object', - properties: { - query: { type: 'string' } - } - } - } - ]) - }; - RegisterSingleton(Services.AIFunctionDefinitions, mockFunctionDefinitions); - // Mock IAIFileService const mockFileService = { refreshCache: vi.fn().mockResolvedValue(undefined), @@ -118,13 +99,11 @@ describe('OpenAI', () => { const plugin = Resolve(Services.VaultkeeperAIPlugin); const settingsService = Resolve(Services.SettingsService); const streaming = Resolve(Services.StreamingService); - const functions = Resolve(Services.AIFunctionDefinitions); expect(prompt).toBe(mockPrompt); expect(plugin).toBe(mockPlugin); expect(settingsService).toBe(mockSettingsService); expect(streaming).toBe(mockStreamingService); - expect(functions).toBe(mockFunctionDefinitions); }); }); @@ -329,11 +308,16 @@ describe('OpenAI', () => { const conversation = new Conversation(); conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Hello' })); + // Set system prompts before calling streamRequest + openai.systemPrompt = 'System instruction'; + openai.userInstruction = 'User instruction'; + openai.toolDefinitions = []; + mockStreamingService.streamRequest.mockImplementation(async function* () { yield { content: 'response', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); // Consume generator for await (const chunk of generator) { @@ -367,7 +351,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -410,7 +394,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -439,7 +423,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -465,7 +449,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -487,7 +471,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -518,7 +502,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -563,7 +547,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -608,7 +592,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -662,7 +646,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -695,7 +679,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -728,7 +712,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -768,7 +752,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -834,7 +818,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; @@ -915,7 +899,7 @@ describe('OpenAI', () => { yield { content: 'response', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) { // Just consume @@ -946,7 +930,7 @@ describe('OpenAI', () => { yield { content: 'done', isComplete: true }; }); - const generator = openai.streamRequest(conversation, true); + const generator = openai.streamRequest(conversation); for await (const chunk of generator) {} const callArgs = mockStreamingService.streamRequest.mock.calls[0]; diff --git a/__tests__/Services/AIControllerService.test.ts b/__tests__/Services/AIControllerService.test.ts new file mode 100644 index 0000000..40ced49 --- /dev/null +++ b/__tests__/Services/AIControllerService.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AIControllerService } from '../../Services/AIControllerService'; +import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { Conversation } from '../../Conversations/Conversation'; +import { ConversationContent } from '../../Conversations/ConversationContent'; +import { Role } from '../../Enums/Role'; +import { AIFunction } from '../../Enums/AIFunction'; + +/** + * INTEGRATION TESTS - AIControllerService + * + * Tests the multi-agent controller service that orchestrates: + * - Main agent execution + * - Planning agent workflow + * - Execution agent workflow + * - Agent loop management + */ + +describe('AIControllerService - Integration Tests', () => { + let service: AIControllerService; + let mockAI: any; + let mockPrompt: any; + let mockAIFunctionService: any; + + beforeEach(() => { + // Mock IPrompt + mockPrompt = { + systemInstruction: vi.fn().mockReturnValue('System instruction'), + userInstruction: vi.fn().mockResolvedValue('User instruction'), + planningInstruction: vi.fn().mockReturnValue('Planning instruction') + }; + RegisterSingleton(Services.IPrompt, mockPrompt); + + // Mock AIFunctionService + mockAIFunctionService = { + performAIFunction: vi.fn().mockResolvedValue({ + name: AIFunction.SearchVaultFiles, + response: [], + toolId: 'test-tool-id' + }) + }; + RegisterSingleton(Services.AIFunctionService, mockAIFunctionService); + + // Mock IAIClass + mockAI = { + systemPrompt: '', + userInstruction: '', + toolDefinitions: [], + streamRequest: vi.fn() + }; + RegisterSingleton(Services.IAIClass, mockAI); + + // Create service + service = new AIControllerService(); + }); + + afterEach(() => { + DeregisterAllServices(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with all required services', () => { + const testService = new AIControllerService(); + + expect(testService).toBeDefined(); + }); + }); + + describe('resolveAIProvider', () => { + it('should resolve AI provider', () => { + service.resolveAIProvider(); + + // Should not throw + expect(service).toBeDefined(); + }); + }); + + describe('runMainAgent', () => { + it('should set system prompt and user instruction', async () => { + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test request' })); + + const callbacks = { + onSubmit: vi.fn(), + onStreamingUpdate: vi.fn(), + onThoughtUpdate: vi.fn(), + onComplete: vi.fn(), + onCancel: vi.fn() + }; + + // Mock streamRequest to return immediately without function calls + mockAI.streamRequest.mockImplementation(async function* () { + yield { content: 'Response', isComplete: true }; + }); + + service.resolveAIProvider(); + await service.runMainAgent(conversation, true, callbacks); + + // Verify system prompt and user instruction were set + expect(mockPrompt.systemInstruction).toHaveBeenCalled(); + expect(mockPrompt.userInstruction).toHaveBeenCalled(); + }); + + it('should handle streaming response without function calls', async () => { + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test request' })); + + const callbacks = { + onSubmit: vi.fn(), + onStreamingUpdate: vi.fn(), + onThoughtUpdate: vi.fn(), + onComplete: vi.fn(), + onCancel: vi.fn() + }; + + mockAI.streamRequest.mockImplementation(async function* () { + yield { content: 'Part 1', isComplete: false }; + yield { content: ' Part 2', isComplete: true }; + }); + + service.resolveAIProvider(); + await service.runMainAgent(conversation, true, callbacks); + + // Should have added assistant message to conversation + expect(conversation.contents.length).toBeGreaterThan(1); + const lastMessage = conversation.contents[conversation.contents.length - 1]; + expect(lastMessage.role).toBe(Role.Assistant); + expect(lastMessage.content).toContain('Part 1'); + }); + + it('should throw error if AI provider not resolved', async () => { + const conversation = new Conversation(); + const callbacks = { + onSubmit: vi.fn(), + onStreamingUpdate: vi.fn(), + onThoughtUpdate: vi.fn(), + onComplete: vi.fn(), + onCancel: vi.fn() + }; + + // Don't call resolveAIProvider() + await expect(service.runMainAgent(conversation, true, callbacks)) + .rejects.toThrow('Error: No AI provider has been set!'); + }); + }); + + describe('ensureCorrectConversationStructure', () => { + it('should insert Continue message when last message is from assistant', async () => { + const conversation = new Conversation(); + conversation.contents.push(new ConversationContent({ role: Role.User, content: 'First' })); + conversation.contents.push(new ConversationContent({ role: Role.Assistant, content: 'Response' })); + + const callbacks = { + onSubmit: vi.fn(), + onStreamingUpdate: vi.fn(), + onThoughtUpdate: vi.fn(), + onComplete: vi.fn(), + onCancel: vi.fn() + }; + + mockAI.streamRequest.mockImplementation(async function* () { + yield { content: 'New response', isComplete: true }; + }); + + service.resolveAIProvider(); + await service.runMainAgent(conversation, true, callbacks); + + // Should have inserted Continue message between last assistant and new assistant + const continueMessage = conversation.contents.find(c => + c.role === Role.User && c.content === 'Continue' && c.shouldDisplayContent === false + ); + expect(continueMessage).toBeDefined(); + }); + }); +}); diff --git a/__tests__/Services/AIFunctionService.test.ts b/__tests__/Services/AIFunctionService.test.ts index c9f3436..bad5d83 100644 --- a/__tests__/Services/AIFunctionService.test.ts +++ b/__tests__/Services/AIFunctionService.test.ts @@ -550,7 +550,7 @@ describe('AIFunctionService - Integration Tests', () => { toolId: 'tool_patch_invalid' } as any); - expect(result.response.error).toContain('Invalid arguments for PatchVaultFile'); + expect(result.response.error).toContain('Invalid arguments for patch_vault_file'); expect(mockFileSystemService.patchFile).not.toHaveBeenCalled(); }); }); @@ -782,28 +782,20 @@ describe('AIFunctionService - Integration Tests', () => { }); describe('performAIFunction - Unknown Function', () => { - it('should return error for unknown function', async () => { - const result = await service.performAIFunction({ + it('should throw error for unknown function', async () => { + await expect(service.performAIFunction({ name: 'UnknownFunction' as any, arguments: {}, toolId: 'tool_27' - } as any); - - expect(result.response).toEqual({ - error: 'Unknown function request UnknownFunction' - }); + } as any)).rejects.toThrow('Unknown function name: UnknownFunction'); }); - it('should preserve toolId in error response', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - - const result = await service.performAIFunction({ + it('should throw error for invalid function', async () => { + await expect(service.performAIFunction({ name: 'InvalidFunction' as any, arguments: {}, toolId: 'tool_error' - } as any); - - expect(result.toolId).toBe('tool_error'); + } as any)).rejects.toThrow('Unknown function name: InvalidFunction'); }); }); diff --git a/__tests__/Services/ChatService.test.ts b/__tests__/Services/ChatService.test.ts index c769cbf..c2d1373 100644 --- a/__tests__/Services/ChatService.test.ts +++ b/__tests__/Services/ChatService.test.ts @@ -2,10 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ChatService } from '../../Services/ChatService'; import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; import { Services } from '../../Services/Services'; -import { Conversation } from '../../Conversations/Conversation'; -import { ConversationContent } from '../../Conversations/ConversationContent'; -import { Role } from '../../Enums/Role'; -import { AIFunctionCall } from '../../AIClasses/AIFunctionCall'; import { AIFunction, fromString } from '../../Enums/AIFunction'; import { AbortService } from '../../Services/AbortService'; @@ -22,10 +18,8 @@ import { AbortService } from '../../Services/AbortService'; describe('ChatService - Integration Tests (Sync Methods Only)', () => { let service: ChatService; let mockConversationService: any; - let mockAIFunctionService: any; + let mockAIControllerService: any; let mockNamingService: any; - let mockPrompt: any; - let mockStatusBarService: any; let mockEventService: any; let mockWorkSpaceService: any; let abortService: AbortService; @@ -36,23 +30,14 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { saveConversation: vi.fn() }; - mockAIFunctionService = { - performAIFunction: vi.fn() + mockAIControllerService = { + runMainAgent: vi.fn() }; mockNamingService = { requestName: vi.fn() }; - mockPrompt = { - systemInstruction: vi.fn().mockReturnValue('System prompt'), - userInstruction: vi.fn().mockResolvedValue('User prompt') - }; - - mockStatusBarService = { - animateTokens: vi.fn() - }; - // Mock EventService since it extends Obsidian's Events class mockEventService = { trigger: vi.fn(), @@ -71,9 +56,8 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { // Register dependencies RegisterSingleton(Services.ConversationFileSystemService, mockConversationService); - RegisterSingleton(Services.AIFunctionService, mockAIFunctionService); + RegisterSingleton(Services.AIControllerService, mockAIControllerService); RegisterSingleton(Services.ConversationNamingService, mockNamingService); - RegisterSingleton(Services.IPrompt, mockPrompt); RegisterSingleton(Services.EventService, mockEventService); RegisterSingleton(Services.WorkSpaceService, mockWorkSpaceService); RegisterSingleton(Services.AbortService, abortService); @@ -100,18 +84,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { }); }); - describe('resolveAIProvider', () => { - it('should resolve AI provider services', () => { - const mockAI = { streamRequest: vi.fn() }; - RegisterSingleton(Services.IAIClass, mockAI as any); - - service.resolveAIProvider(); - - // Should not throw - expect(service).toBeDefined(); - }); - }); - describe('stop', () => { it('should not throw when called with no active request', () => { expect(() => service.stop()).not.toThrow(); @@ -142,397 +114,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { }); }); - describe('assistant-to-assistant message prevention', () => { - it('should insert Continue message when last message is from assistant', () => { - const conversation = new Conversation(); - // Add an initial user message - conversation.contents.push(new ConversationContent({ role: Role.User, content: 'First message' })); - // Add an assistant response - conversation.contents.push(new ConversationContent({ role: Role.Assistant, content: 'Assistant response' })); - - // Verify the last message is from assistant - expect(conversation.contents[conversation.contents.length - 1].role).toBe(Role.Assistant); - expect(conversation.contents.length).toBe(2); - - // Now we need to simulate what happens in submit() when a new user message is added - // Check if the last message is from the assistant (like the fix does) - if (conversation.contents.length > 0) { - const lastMessage = conversation.contents[conversation.contents.length - 1]; - if (lastMessage.role === Role.Assistant) { - conversation.contents.push(ConversationContent.safeContinue()); - } - } - - // Verify Continue message was inserted - expect(conversation.contents.length).toBe(3); - const continueMessage = conversation.contents[2]; - expect(continueMessage.role).toBe(Role.User); - expect(continueMessage.content).toBe('Continue'); - expect(continueMessage.shouldDisplayContent).toBe(false); - }); - - it('should NOT insert Continue message when last message is from user', () => { - const conversation = new Conversation(); - // Add a user message - conversation.contents.push(new ConversationContent({ role: Role.User, content: 'User message' })); - - // Verify the last message is from user - expect(conversation.contents[conversation.contents.length - 1].role).toBe(Role.User); - expect(conversation.contents.length).toBe(1); - - // Simulate the check from submit() - if (conversation.contents.length > 0) { - const lastMessage = conversation.contents[conversation.contents.length - 1]; - if (lastMessage.role === Role.Assistant) { - conversation.contents.push(ConversationContent.safeContinue()); - } - } - - // Verify NO Continue message was inserted - expect(conversation.contents.length).toBe(1); - }); - - it('should NOT insert Continue message for empty conversation', () => { - const conversation = new Conversation(); - - // Verify empty - expect(conversation.contents.length).toBe(0); - - // Simulate the check from submit() - if (conversation.contents.length > 0) { - const lastMessage = conversation.contents[conversation.contents.length - 1]; - if (lastMessage.role === Role.Assistant) { - conversation.contents.push(ConversationContent.safeContinue()); - } - } - - // Verify still empty - expect(conversation.contents.length).toBe(0); - }); - - it('should maintain proper structure after Continue message insertion', () => { - const conversation = new Conversation(); - conversation.contents.push(new ConversationContent({ role: Role.User, content: 'First' })); - conversation.contents.push(new ConversationContent({ role: Role.Assistant, content: 'Response' })); - - // Simulate the fix - if (conversation.contents.length > 0) { - const lastMessage = conversation.contents[conversation.contents.length - 1]; - if (lastMessage.role === Role.Assistant) { - conversation.contents.push(ConversationContent.safeContinue()); - } - } - - // Add the actual user message - conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Second message' })); - - // Verify the structure: User -> Assistant -> User(Continue) -> User - expect(conversation.contents.length).toBe(4); - expect(conversation.contents[0].role).toBe(Role.User); - expect(conversation.contents[0].content).toBe('First'); - expect(conversation.contents[1].role).toBe(Role.Assistant); - expect(conversation.contents[1].content).toBe('Response'); - expect(conversation.contents[2].role).toBe(Role.User); - expect(conversation.contents[2].content).toBe('Continue'); - expect(conversation.contents[2].shouldDisplayContent).toBe(false); - expect(conversation.contents[3].role).toBe(Role.User); - expect(conversation.contents[3].content).toBe('Second message'); - }); - - it('should work with function call responses in conversation', () => { - const conversation = new Conversation(); - conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Request' })); - conversation.contents.push(new ConversationContent({ - role: Role.Assistant, - content: 'Making a function call', - functionCall: '{"name":"test"}', - timestamp: new Date() - })); - // Function response (already User role with functionResponse) - conversation.contents.push(new ConversationContent({ - role: Role.User, - content: 'Function result', - displayContent: 'Function result', - functionResponse: '', - timestamp: new Date(), - toolId: 'tool-1' - })); - // Assistant processes the function response - conversation.contents.push(new ConversationContent({ role: Role.Assistant, content: 'Final response' })); - - // Now the last message is Assistant, so Continue should be inserted - expect(conversation.contents[conversation.contents.length - 1].role).toBe(Role.Assistant); - - // Simulate the fix - if (conversation.contents.length > 0) { - const lastMessage = conversation.contents[conversation.contents.length - 1]; - if (lastMessage.role === Role.Assistant) { - conversation.contents.push(ConversationContent.safeContinue()); - } - } - - // Verify Continue was inserted - expect(conversation.contents.length).toBe(5); - expect(conversation.contents[4].content).toBe('Continue'); - expect(conversation.contents[4].shouldDisplayContent).toBe(false); - }); - }); - - describe('sanitizeFunctionCallContent (private method tests via reflection)', () => { - // Access private method for testing - const getSanitizeMethod = (service: ChatService) => { - return (service as any).sanitizeFunctionCallContent.bind(service); - }; - - it('should return content unchanged when no function call is provided', () => { - const sanitize = getSanitizeMethod(service); - const content = 'This is regular content with {"some": "json"}'; - - const result = sanitize(content, null); - - expect(result).toBe(content); - }); - - it('should return content unchanged when content is empty', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { arg1: 'value1' }); - - const result = sanitize('', functionCall); - - expect(result).toBe(''); - }); - - it('should return content unchanged when content is only whitespace', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { arg1: 'value1' }); - - const result = sanitize(' \n\t ', functionCall); - - expect(result).toBe(' \n\t '); - }); - - it('should remove exact function call JSON from content', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.WriteVaultFile, { - file_path: 'test.md', - content: 'Hello world' - }); - const functionCallString = functionCall.toConversationString(); - const content = `Here is the function call: ${functionCallString}`; - - const result = sanitize(content, functionCall); - - expect(result).toBe('Here is the function call:'); - }); - - it('should remove pretty-printed function call JSON (2 spaces)', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { - query: 'test query', - limit: 10 - }); - - const prettyPrinted = JSON.stringify(JSON.parse(functionCall.toConversationString()), null, 2); - const content = `Function call:\n${prettyPrinted}\nEnd of call`; - - const result = sanitize(content, functionCall); - - expect(result).toBe('Function call:\n\nEnd of call'); - }); - - it('should remove pretty-printed function call JSON (4 spaces)', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.ReadVaultFiles, { - file_path: 'example.ts' - }); - - const prettyPrinted = JSON.stringify(JSON.parse(functionCall.toConversationString()), null, 4); - const content = prettyPrinted; - - const result = sanitize(content, functionCall); - - expect(result).toBe(''); - }); - - it('should preserve legitimate JSON content when no function call exists', () => { - const sanitize = getSanitizeMethod(service); - const legitimateJson = JSON.stringify({ - user: 'test', - data: { nested: 'value' } - }, null, 2); - const content = `Here is your data:\n${legitimateJson}`; - - const result = sanitize(content, null); - - expect(result).toBe(content); - }); - - it('should only remove function call JSON, preserving other content', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.DeleteVaultFiles, { - file_path: 'old.txt' - }); - const functionCallString = functionCall.toConversationString(); - const content = `Processing your request...\n${functionCallString}\nOperation completed successfully.`; - - const result = sanitize(content, functionCall); - - expect(result).toBe('Processing your request...\n\nOperation completed successfully.'); - }); - - it('should handle function calls with special characters in arguments', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.WriteVaultFile, { - file_path: 'path/to/file.ts', - content: 'const regex = /test.*pattern/g;\nfunction() { return "value"; }' - }); - const functionCallString = functionCall.toConversationString(); - const content = functionCallString; - - const result = sanitize(content, functionCall); - - expect(result).toBe(''); - }); - - it('should handle function calls with nested objects', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { - config: { - nested: { - deeply: { - value: 'test' - } - } - }, - array: [1, 2, 3] - }); - const functionCallString = functionCall.toConversationString(); - const content = `Before\n${functionCallString}\nAfter`; - - const result = sanitize(content, functionCall); - - expect(result).toBe('Before\n\nAfter'); - }); - - it('should handle function calls with toolId', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { arg: 'value' }, 'tool-123'); - const functionCallString = functionCall.toConversationString(); - const content = functionCallString; - - const result = sanitize(content, functionCall); - - expect(result).toBe(''); - }); - - it('should not remove similar but different JSON structures', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.WriteVaultFile, { - file_path: 'test.md' - }); - - // Similar but different structure - should not be removed - const differentJson = JSON.stringify({ - functionCall: { - name: 'write_file', - args: { file_path: 'different.md' } - } - }); - const content = `Here is a different call: ${differentJson}`; - - const result = sanitize(content, functionCall); - - // Should keep the different JSON since it doesn't match exactly - expect(result).toContain('different.md'); - }); - - it('should handle multiple whitespace variations in pretty-printed JSON', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { key: 'value' }); - - // Content with irregular whitespace - const content = `{ - "functionCall": { - "name": "search_vault_files", - "args": { - "key": "value" - } - } -}`; - - const result = sanitize(content, functionCall); - - expect(result).toBe(''); - }); - - it('should preserve natural language content when function call exists', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.WriteVaultFile, { - content: 'Character sheet content', - file_name: 'Walker.md' - }); - const content = 'I have created a note for Omaz and will now create a note for walker'; - - const result = sanitize(content, functionCall); - - // Natural language content should be preserved - it doesn't look like JSON - expect(result).toBe(content); - }); - - it('should preserve content that does not start with {', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { - search_terms: ['Walker'] - }); - const content = 'Searching for details about Walker in Campaign 2 folder.'; - - const result = sanitize(content, functionCall); - - // Content doesn't look like JSON, should be preserved - expect(result).toBe(content); - }); - - it('should preserve content that does not end with }', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { key: 'value' }); - const content = '{ "incomplete": "json"'; - - const result = sanitize(content, functionCall); - - // Malformed JSON should be preserved - expect(result).toBe(content); - }); - - it('should preserve content with mixed text and JSON-like structures', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.SearchVaultFiles, { key: 'value' }); - const content = 'Here is some info: { "data": "example" } and more text'; - - const result = sanitize(content, functionCall); - - // Mixed content should be preserved - doesn't match the exact JSON pattern - expect(result).toBe(content); - }); - - it('should only sanitize when content is EXACTLY matching JSON structure', () => { - const sanitize = getSanitizeMethod(service); - const functionCall = new AIFunctionCall(AIFunction.WriteVaultFile, { - file_path: 'test.md', - content: 'Hello' - }); - const functionCallString = functionCall.toConversationString(); - - // Only exact matches should be removed - const exactMatch = functionCallString; - const withPrefix = `Creating file: ${functionCallString}`; - const withSuffix = `${functionCallString} - done`; - - expect(sanitize(exactMatch, functionCall)).toBe(''); - expect(sanitize(withPrefix, functionCall)).toBe('Creating file:'); - expect(sanitize(withSuffix, functionCall)).toBe('- done'); - }); - }); - describe('fromString', () => { it('should convert valid function name strings to AIFunction enum', () => { expect(fromString('search_vault_files')).toBe(AIFunction.SearchVaultFiles); diff --git a/tsconfig.json b/tsconfig.json index eaa5f5e..ea67d13 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,14 +18,26 @@ "ES5", "ES6", "ES7" - ] + ], + "paths": { + "Helpers/*": ["./Helpers/*"], + "Enums/*": ["./Enums/*"], + "Services/*": ["./Services/*"], + "Conversations/*": ["./Conversations/*"], + "AIClasses/*": ["./AIClasses/*"], + "AIPrompts/*": ["./AIPrompts/*"], + "Components/*": ["./Components/*"], + "Stores/*": ["./Stores/*"], + "Views/*": ["./Views/*"], + "Modals/*": ["./Modals/*"], + "Types/*": ["./Types/*"] + } }, "include": [ "**/*.ts", "**/*.svelte" ], "exclude": [ - "node_modules", - "__tests__" + "node_modules" ] } \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 3b005c7..77807d6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "Services": path.resolve(__dirname, "Services"), "Conversations": path.resolve(__dirname, "Conversations"), "AIClasses": path.resolve(__dirname, "AIClasses"), + "AIPrompts": path.resolve(__dirname, "AIPrompts"), "Components": path.resolve(__dirname, "Components"), "Stores": path.resolve(__dirname, "Stores"), "Views": path.resolve(__dirname, "Views"),