refactor: update tests for AIFunctionService and path alias changes

- Remove AIFunctionDefinitions mock from AI class tests
- Update IPrompt import path to AIPrompts directory
- Remove deprecated streamRequest boolean parameter
- Fix error handling assertions in AIFunctionService tests
- Remove ChatService sanitization and assistant message tests
- Add path aliases to tsconfig and vitest config
This commit is contained in:
Andrew Beal 2025-12-30 21:58:54 +00:00
parent 2de8109a74
commit a4daa3a9df
8 changed files with 247 additions and 514 deletions

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const settingsService = Resolve<SettingsService>(Services.SettingsService);
const streaming = Resolve<StreamingService>(Services.StreamingService);
const functions = Resolve<AIFunctionDefinitions>(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();

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const settingsService = Resolve<SettingsService>(Services.SettingsService);
const streaming = Resolve<StreamingService>(Services.StreamingService);
const functions = Resolve<AIFunctionDefinitions>(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)

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const settingsService = Resolve<SettingsService>(Services.SettingsService);
const streaming = Resolve<StreamingService>(Services.StreamingService);
const functions = Resolve<AIFunctionDefinitions>(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];

View file

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

View file

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

View file

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

View file

@ -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"
]
}

View file

@ -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"),