mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
test: add artifact tracking tests for file operations
Add comprehensive test coverage for artifact generation in AIToolService and ConversationFileSystemService, including write/patch/delete operations, binary file handling, serialization/deserialization, and garbage collection.
This commit is contained in:
parent
10ddb1da28
commit
6b31e3d4e9
4 changed files with 828 additions and 1 deletions
205
__tests__/Conversations/Artifact.test.ts
Normal file
205
__tests__/Conversations/Artifact.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Artifact } from '../../Conversations/Artifact';
|
||||
|
||||
/**
|
||||
* UNIT TESTS - Artifact
|
||||
*
|
||||
* Tests the Artifact class that tracks file modifications made by the
|
||||
* AI agent during a conversation turn (write/patch/delete tracking).
|
||||
*/
|
||||
|
||||
describe('Artifact', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create an artifact with required fields', () => {
|
||||
const artifact = new Artifact('notes/test.md', 'text/markdown', 'old content', 'new content');
|
||||
|
||||
expect(artifact.filePath).toBe('notes/test.md');
|
||||
expect(artifact.mimeType).toBe('text/markdown');
|
||||
expect(artifact.originalContent).toBe('old content');
|
||||
expect(artifact.updatedContent).toBe('new content');
|
||||
expect(artifact.base64).toBeUndefined();
|
||||
expect(artifact.artifactPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create an artifact with optional base64 and artifactPath', () => {
|
||||
const artifact = new Artifact(
|
||||
'image.png',
|
||||
'image/png',
|
||||
'',
|
||||
'',
|
||||
'base64data==',
|
||||
'Artifacts/hash123.bin'
|
||||
);
|
||||
|
||||
expect(artifact.base64).toBe('base64data==');
|
||||
expect(artifact.artifactPath).toBe('Artifacts/hash123.bin');
|
||||
});
|
||||
|
||||
it('should allow empty originalContent for newly created files', () => {
|
||||
const artifact = new Artifact('new-file.md', 'text/markdown', '', 'Some new content');
|
||||
|
||||
expect(artifact.originalContent).toBe('');
|
||||
expect(artifact.updatedContent).toBe('Some new content');
|
||||
});
|
||||
|
||||
it('should allow empty updatedContent for deleted files', () => {
|
||||
const artifact = new Artifact('deleted-file.md', 'text/markdown', 'Content before deletion', '');
|
||||
|
||||
expect(artifact.originalContent).toBe('Content before deletion');
|
||||
expect(artifact.updatedContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoragePath / setStoragePath', () => {
|
||||
it('should return undefined when no storage path has been set', () => {
|
||||
const artifact = new Artifact('test.md', 'text/markdown', 'a', 'b');
|
||||
|
||||
expect(artifact.getStoragePath()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the artifactPath passed to the constructor', () => {
|
||||
const artifact = new Artifact('test.md', 'text/markdown', 'a', 'b', 'base64', 'Artifacts/existing.bin');
|
||||
|
||||
expect(artifact.getStoragePath()).toBe('Artifacts/existing.bin');
|
||||
});
|
||||
|
||||
it('should update the storage path when set', () => {
|
||||
const artifact = new Artifact('test.md', 'text/markdown', 'a', 'b');
|
||||
|
||||
artifact.setStoragePath('Artifacts/newly-saved.bin');
|
||||
|
||||
expect(artifact.getStoragePath()).toBe('Artifacts/newly-saved.bin');
|
||||
expect(artifact.artifactPath).toBe('Artifacts/newly-saved.bin');
|
||||
});
|
||||
|
||||
it('should overwrite an existing storage path', () => {
|
||||
const artifact = new Artifact('test.md', 'text/markdown', 'a', 'b', undefined, 'Artifacts/old.bin');
|
||||
|
||||
artifact.setStoragePath('Artifacts/new.bin');
|
||||
|
||||
expect(artifact.getStoragePath()).toBe('Artifacts/new.bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isArtifactData', () => {
|
||||
it('should return true for valid minimal artifact data', () => {
|
||||
const data = {
|
||||
filePath: 'test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new'
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid artifact data with optional fields', () => {
|
||||
const data = {
|
||||
filePath: 'test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new',
|
||||
base64: 'YWJj',
|
||||
artifactPath: 'Artifacts/hash.bin'
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(Artifact.isArtifactData(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object values', () => {
|
||||
expect(Artifact.isArtifactData('a string')).toBe(false);
|
||||
expect(Artifact.isArtifactData(42)).toBe(false);
|
||||
expect(Artifact.isArtifactData(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when filePath is missing', () => {
|
||||
const data = {
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new'
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when mimeType is missing', () => {
|
||||
const data = {
|
||||
filePath: 'test.md',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new'
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when originalContent is missing', () => {
|
||||
const data = {
|
||||
filePath: 'test.md',
|
||||
mimeType: 'text/markdown',
|
||||
updatedContent: 'new'
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when updatedContent is missing', () => {
|
||||
const data = {
|
||||
filePath: 'test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old'
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when filePath has wrong type', () => {
|
||||
const data = {
|
||||
filePath: 123,
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new'
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when base64 has wrong type', () => {
|
||||
const data = {
|
||||
filePath: 'test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new',
|
||||
base64: 12345
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when artifactPath has wrong type', () => {
|
||||
const data = {
|
||||
filePath: 'test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new',
|
||||
artifactPath: true
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow empty string content fields', () => {
|
||||
const data = {
|
||||
filePath: 'new-file.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: '',
|
||||
updatedContent: ''
|
||||
};
|
||||
|
||||
expect(Artifact.isArtifactData(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { TFile } from 'obsidian';
|
|||
import { Exception } from '../../Helpers/Exception';
|
||||
import { AbortService } from '../../Services/AbortService';
|
||||
import { ChatMode } from '../../Enums/ChatMode';
|
||||
import { Artifact } from '../../Conversations/Artifact';
|
||||
|
||||
/**
|
||||
* INTEGRATION TESTS - AIToolService
|
||||
|
|
@ -321,6 +322,64 @@ describe('AIToolService - Integration Tests', () => {
|
|||
expect((result.payload.response as any).error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should produce an artifact tracking the before/after content on success', async () => {
|
||||
mockFileSystemService.readFilePath.mockResolvedValue('Old content');
|
||||
mockFileSystemService.writeToFilePath.mockResolvedValue(createMockFile('notes/new-note.md', 'new-note'));
|
||||
mockFileSystemService.readFile.mockResolvedValue('# New Note\n\nContent here');
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.WriteVaultFile,
|
||||
arguments: {
|
||||
file_path: 'notes/new-note.md',
|
||||
content: '# New Note\n\nContent here',
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_11b'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts).toHaveLength(1);
|
||||
const artifact = result.payload.artifacts[0];
|
||||
expect(artifact).toBeInstanceOf(Artifact);
|
||||
expect(artifact.filePath).toBe('notes/new-note.md');
|
||||
expect(artifact.originalContent).toBe('Old content');
|
||||
expect(artifact.updatedContent).toBe('# New Note\n\nContent here');
|
||||
});
|
||||
|
||||
it('should track empty originalContent when the file did not previously exist', async () => {
|
||||
mockFileSystemService.readFilePath.mockResolvedValue(new Error('File does not exist'));
|
||||
mockFileSystemService.writeToFilePath.mockResolvedValue(createMockFile('brand-new.md', 'brand-new'));
|
||||
mockFileSystemService.readFile.mockResolvedValue('Brand new content');
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.WriteVaultFile,
|
||||
arguments: {
|
||||
file_path: 'brand-new.md',
|
||||
content: 'Brand new content',
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_11c'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts[0].originalContent).toBe('');
|
||||
expect(result.payload.artifacts[0].updatedContent).toBe('Brand new content');
|
||||
});
|
||||
|
||||
it('should not produce an artifact when the write fails', async () => {
|
||||
mockFileSystemService.writeToFilePath.mockResolvedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.WriteVaultFile,
|
||||
arguments: {
|
||||
file_path: 'protected.md',
|
||||
content: 'Content',
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_11d'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('should normalize file path', async () => {
|
||||
mockFileSystemService.writeToFilePath.mockResolvedValue(undefined);
|
||||
|
||||
|
|
@ -381,6 +440,48 @@ describe('AIToolService - Integration Tests', () => {
|
|||
expect(result.payload.response).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should produce an artifact tracking the before/after content on success', async () => {
|
||||
mockFileSystemService.readFilePath.mockResolvedValue('# Title\nold content');
|
||||
mockFileSystemService.patchFileAtPath.mockResolvedValue(createMockFile('notes/test.md', 'test'));
|
||||
mockFileSystemService.readFile.mockResolvedValue('# Title\nnew content');
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.PatchVaultFile,
|
||||
arguments: {
|
||||
file_path: 'notes/test.md',
|
||||
oldContent: ['old content'],
|
||||
newContent: ['new content'],
|
||||
user_message: 'Updating test note'
|
||||
},
|
||||
toolId: 'tool_patch_1b'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts).toHaveLength(1);
|
||||
const artifact = result.payload.artifacts[0];
|
||||
expect(artifact).toBeInstanceOf(Artifact);
|
||||
expect(artifact.filePath).toBe('notes/test.md');
|
||||
expect(artifact.originalContent).toBe('# Title\nold content');
|
||||
expect(artifact.updatedContent).toBe('# Title\nnew content');
|
||||
});
|
||||
|
||||
it('should not produce an artifact when the patch fails', async () => {
|
||||
mockFileSystemService.readFilePath.mockResolvedValue('# Title\nold content');
|
||||
mockFileSystemService.patchFileAtPath.mockResolvedValue(new Error('Content to replace was not found in the file'));
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.PatchVaultFile,
|
||||
arguments: {
|
||||
file_path: 'notes/test.md',
|
||||
oldContent: ['missing content'],
|
||||
newContent: ['new content'],
|
||||
user_message: 'Updating test note'
|
||||
},
|
||||
toolId: 'tool_patch_1c'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle patch failure', async () => {
|
||||
const error = new Error('Content to replace was not found in the file');
|
||||
mockFileSystemService.patchFileAtPath.mockResolvedValue(error);
|
||||
|
|
@ -671,6 +772,133 @@ describe('AIToolService - Integration Tests', () => {
|
|||
|
||||
expect((result.payload.response as any).results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should produce artifacts capturing content of deleted text files', async () => {
|
||||
mockFileSystemService.readFilePath.mockResolvedValue('Content before deletion');
|
||||
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.DeleteVaultFiles,
|
||||
arguments: {
|
||||
file_paths: ['notes/gone.md'],
|
||||
confirm_deletion: true,
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_19b'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts).toHaveLength(1);
|
||||
const artifact = result.payload.artifacts[0];
|
||||
expect(artifact).toBeInstanceOf(Artifact);
|
||||
expect(artifact.filePath).toBe('notes/gone.md');
|
||||
expect(artifact.originalContent).toBe('Content before deletion');
|
||||
expect(artifact.updatedContent).toBe('');
|
||||
});
|
||||
|
||||
it('should produce base64 artifacts capturing content of deleted binary files', async () => {
|
||||
const buffer = new TextEncoder().encode('binary-bytes').buffer;
|
||||
mockFileSystemService.readBinaryFile.mockResolvedValue(buffer);
|
||||
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.DeleteVaultFiles,
|
||||
arguments: {
|
||||
file_paths: ['images/gone.png'],
|
||||
confirm_deletion: true,
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_19c'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts).toHaveLength(1);
|
||||
const artifact = result.payload.artifacts[0];
|
||||
expect(artifact.filePath).toBe('images/gone.png');
|
||||
expect(artifact.base64).toBeDefined();
|
||||
expect(mockFileSystemService.readFilePath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not collect artifacts when confirmation is false', async () => {
|
||||
const result = await service.performAITool({
|
||||
name: AITool.DeleteVaultFiles,
|
||||
arguments: {
|
||||
file_paths: ['notes/gone.md'],
|
||||
confirm_deletion: false,
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_19d'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.artifacts).toEqual([]);
|
||||
expect(mockFileSystemService.readFilePath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('performAITool - DeleteVaultFolder', () => {
|
||||
it('should delete folder successfully and produce artifacts for its contents', async () => {
|
||||
mockFileSystemService.listDirectoryContents = vi.fn().mockResolvedValue([
|
||||
createMockFile('folder/a.md', 'a'),
|
||||
createMockFile('folder/b.md', 'b')
|
||||
]);
|
||||
mockFileSystemService.readFilePath
|
||||
.mockResolvedValueOnce('Content A')
|
||||
.mockResolvedValueOnce('Content B');
|
||||
mockFileSystemService.deleteFolder = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.DeleteVaultFolder,
|
||||
arguments: {
|
||||
path: 'folder',
|
||||
confirm_deletion: true,
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_folder_1'
|
||||
} as any);
|
||||
|
||||
expect(mockFileSystemService.deleteFolder).toHaveBeenCalledWith('folder');
|
||||
expect(result.payload.response).toEqual({ path: 'folder', success: true });
|
||||
expect(result.payload.artifacts).toHaveLength(2);
|
||||
expect(result.payload.artifacts.map((a: Artifact) => a.filePath)).toEqual(['folder/a.md', 'folder/b.md']);
|
||||
expect(result.payload.artifacts[0].originalContent).toBe('Content A');
|
||||
});
|
||||
|
||||
it('should reject deletion when confirmation is false', async () => {
|
||||
mockFileSystemService.listDirectoryContents = vi.fn();
|
||||
mockFileSystemService.deleteFolder = vi.fn();
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.DeleteVaultFolder,
|
||||
arguments: {
|
||||
path: 'folder',
|
||||
confirm_deletion: false,
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_folder_2'
|
||||
} as any);
|
||||
|
||||
expect(result.payload.response).toEqual({ error: 'Confirmation was false, no action taken' });
|
||||
expect(mockFileSystemService.deleteFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still return collected artifacts when the delete fails', async () => {
|
||||
mockFileSystemService.listDirectoryContents = vi.fn().mockResolvedValue([
|
||||
createMockFile('folder/a.md', 'a')
|
||||
]);
|
||||
mockFileSystemService.readFilePath.mockResolvedValue('Content A');
|
||||
mockFileSystemService.deleteFolder = vi.fn().mockResolvedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await service.performAITool({
|
||||
name: AITool.DeleteVaultFolder,
|
||||
arguments: {
|
||||
path: 'folder',
|
||||
confirm_deletion: true,
|
||||
user_message: 'test search'
|
||||
},
|
||||
toolId: 'tool_folder_3'
|
||||
} as any);
|
||||
|
||||
expect((result.payload.response as any).success).toBe(false);
|
||||
expect(result.payload.artifacts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performAITool - MoveVaultFiles', () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ConversationContent } from '../../Conversations/ConversationContent';
|
|||
import { Role } from '../../Enums/Role';
|
||||
import { TFile } from 'obsidian';
|
||||
import { Exception } from '../../Helpers/Exception';
|
||||
import { Artifact } from '../../Conversations/Artifact';
|
||||
|
||||
/**
|
||||
* INTEGRATION TESTS - ConversationFileSystemService
|
||||
|
|
@ -33,7 +34,9 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
deleteFile: vi.fn(),
|
||||
moveFile: vi.fn(),
|
||||
listFilesInDirectory: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true)
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
writeBinaryFile: vi.fn().mockResolvedValue(new TFile()),
|
||||
readBinaryFile: vi.fn()
|
||||
};
|
||||
|
||||
// Register the mock
|
||||
|
|
@ -527,6 +530,291 @@ describe('ConversationFileSystemService - Integration Tests', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Artifacts', () => {
|
||||
it('should save an artifact with base64 content as a binary file and store its path', async () => {
|
||||
const conversation = createTestConversation('With Artifact');
|
||||
const artifact = new Artifact('notes/test.md', 'text/markdown', 'old', 'new', 'YWJjZGVm');
|
||||
|
||||
conversation.contents.push(
|
||||
new ConversationContent({ role: Role.Assistant, content: 'Edited', artifacts: [artifact] })
|
||||
);
|
||||
|
||||
mockFileSystemService.exists.mockResolvedValue(false);
|
||||
|
||||
await service.saveConversation(conversation);
|
||||
|
||||
expect(mockFileSystemService.writeBinaryFile).toHaveBeenCalledTimes(1);
|
||||
const [writtenPath] = mockFileSystemService.writeBinaryFile.mock.calls[0];
|
||||
expect(writtenPath).toContain('Vaultkeeper AI/Conversations/Artifacts/');
|
||||
expect(artifact.artifactPath).toBeDefined();
|
||||
expect(artifact.artifactPath).not.toContain('Vaultkeeper AI/Conversations/');
|
||||
|
||||
const savedData = mockFileSystemService.writeObjectToFile.mock.calls[0][1];
|
||||
const savedArtifact = savedData.contents[2].artifacts[0];
|
||||
expect(savedArtifact).toMatchObject({
|
||||
filePath: 'notes/test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new',
|
||||
artifactPath: artifact.artifactPath
|
||||
});
|
||||
});
|
||||
|
||||
it('should not write a binary file for artifacts without base64 content (text-only edits)', async () => {
|
||||
const conversation = createTestConversation('Text Only Artifact');
|
||||
const artifact = new Artifact('notes/test.md', 'text/markdown', 'old', 'new');
|
||||
|
||||
conversation.contents.push(
|
||||
new ConversationContent({ role: Role.Assistant, content: 'Edited', artifacts: [artifact] })
|
||||
);
|
||||
|
||||
await service.saveConversation(conversation);
|
||||
|
||||
expect(mockFileSystemService.writeBinaryFile).not.toHaveBeenCalled();
|
||||
expect(artifact.artifactPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not re-write a binary file for an artifact that already has a storage path', async () => {
|
||||
const conversation = createTestConversation('Existing Artifact');
|
||||
const artifact = new Artifact('notes/test.md', 'text/markdown', 'old', 'new', 'YWJjZGVm', 'Artifacts/existing-hash.bin');
|
||||
|
||||
conversation.contents.push(
|
||||
new ConversationContent({ role: Role.Assistant, content: 'Edited', artifacts: [artifact] })
|
||||
);
|
||||
|
||||
await service.saveConversation(conversation);
|
||||
|
||||
expect(mockFileSystemService.writeBinaryFile).not.toHaveBeenCalled();
|
||||
expect(artifact.artifactPath).toBe('Artifacts/existing-hash.bin');
|
||||
});
|
||||
|
||||
it('should deserialize artifacts and load their binary content on getAllConversations', async () => {
|
||||
const mockFiles = [createMockFile('Vaultkeeper AI/Conversations/with-artifact.json')];
|
||||
mockFileSystemService.listFilesInDirectory.mockResolvedValue(mockFiles);
|
||||
mockFileSystemService.readObjectFromFile.mockResolvedValue({
|
||||
title: 'With Artifact',
|
||||
created: '2024-01-01T10:00:00.000Z',
|
||||
updated: '2024-01-01T10:30:00.000Z',
|
||||
contents: [
|
||||
{
|
||||
role: Role.Assistant,
|
||||
content: 'Edited a file',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
artifacts: [
|
||||
{
|
||||
filePath: 'notes/test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new',
|
||||
artifactPath: 'Artifacts/hash123.bin'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
mockFileSystemService.readBinaryFile.mockResolvedValue(new TextEncoder().encode('abcdef').buffer);
|
||||
|
||||
const conversations = await service.getAllConversations();
|
||||
|
||||
expect(mockFileSystemService.readBinaryFile).toHaveBeenCalledWith(
|
||||
'Vaultkeeper AI/Conversations/Artifacts/hash123.bin',
|
||||
true
|
||||
);
|
||||
const artifact = conversations[0].contents[0].artifacts[0];
|
||||
expect(artifact).toBeInstanceOf(Artifact);
|
||||
expect(artifact.filePath).toBe('notes/test.md');
|
||||
expect(artifact.originalContent).toBe('old');
|
||||
expect(artifact.updatedContent).toBe('new');
|
||||
expect(artifact.base64).toBeDefined();
|
||||
});
|
||||
|
||||
it('should deserialize text-only artifacts without attempting to load binary content', async () => {
|
||||
const mockFiles = [createMockFile('Vaultkeeper AI/Conversations/text-artifact.json')];
|
||||
mockFileSystemService.listFilesInDirectory.mockResolvedValue(mockFiles);
|
||||
mockFileSystemService.readObjectFromFile.mockResolvedValue({
|
||||
title: 'Text Artifact',
|
||||
created: '2024-01-01T10:00:00.000Z',
|
||||
updated: '2024-01-01T10:30:00.000Z',
|
||||
contents: [
|
||||
{
|
||||
role: Role.Assistant,
|
||||
content: 'Edited a file',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
artifacts: [
|
||||
{
|
||||
filePath: 'notes/test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const conversations = await service.getAllConversations();
|
||||
|
||||
expect(mockFileSystemService.readBinaryFile).not.toHaveBeenCalled();
|
||||
expect(conversations[0].contents[0].artifacts[0].base64).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip invalid artifact data during deserialization', async () => {
|
||||
const mockFiles = [createMockFile('Vaultkeeper AI/Conversations/invalid-artifact.json')];
|
||||
mockFileSystemService.listFilesInDirectory.mockResolvedValue(mockFiles);
|
||||
mockFileSystemService.readObjectFromFile.mockResolvedValue({
|
||||
title: 'Invalid Artifact',
|
||||
created: '2024-01-01T10:00:00.000Z',
|
||||
updated: '2024-01-01T10:30:00.000Z',
|
||||
contents: [
|
||||
{
|
||||
role: Role.Assistant,
|
||||
content: 'Edited a file',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
artifacts: [
|
||||
{ filePath: 'notes/test.md' } // missing required fields
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const conversations = await service.getAllConversations();
|
||||
|
||||
expect(conversations[0].contents[0].artifacts).toEqual([]);
|
||||
});
|
||||
|
||||
describe('garbageCollectArtifacts', () => {
|
||||
it('should delete artifact files with no remaining references', async () => {
|
||||
mockFileSystemService.listFilesInDirectory.mockImplementation((path: string) => {
|
||||
if (path === 'Vaultkeeper AI/Conversations/Artifacts') {
|
||||
return Promise.resolve([
|
||||
createMockFile('Vaultkeeper AI/Conversations/Artifacts/referenced.bin'),
|
||||
createMockFile('Vaultkeeper AI/Conversations/Artifacts/orphaned.bin')
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([createMockFile('Vaultkeeper AI/Conversations/conv1.json')]);
|
||||
});
|
||||
|
||||
mockFileSystemService.readObjectFromFile.mockResolvedValue({
|
||||
title: 'Conv',
|
||||
created: '2024-01-01T10:00:00.000Z',
|
||||
updated: '2024-01-01T10:00:00.000Z',
|
||||
contents: [
|
||||
{
|
||||
role: Role.Assistant,
|
||||
content: 'Edit',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
artifacts: [
|
||||
{
|
||||
filePath: 'notes/test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new',
|
||||
artifactPath: 'Artifacts/referenced.bin'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
mockFileSystemService.readBinaryFile.mockResolvedValue(new ArrayBuffer(0));
|
||||
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
|
||||
|
||||
await service.garbageCollectArtifacts();
|
||||
|
||||
expect(mockFileSystemService.deleteFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockFileSystemService.deleteFile).toHaveBeenCalledWith(
|
||||
'Vaultkeeper AI/Conversations/Artifacts/orphaned.bin',
|
||||
true,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing when there are no artifact files', async () => {
|
||||
mockFileSystemService.listFilesInDirectory.mockResolvedValue([]);
|
||||
|
||||
const result = await service.garbageCollectArtifacts();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockFileSystemService.deleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not delete any artifact files still referenced by a conversation', async () => {
|
||||
mockFileSystemService.listFilesInDirectory.mockImplementation((path: string) => {
|
||||
if (path === 'Vaultkeeper AI/Conversations/Artifacts') {
|
||||
return Promise.resolve([createMockFile('Vaultkeeper AI/Conversations/Artifacts/kept.bin')]);
|
||||
}
|
||||
return Promise.resolve([createMockFile('Vaultkeeper AI/Conversations/conv1.json')]);
|
||||
});
|
||||
|
||||
mockFileSystemService.readObjectFromFile.mockResolvedValue({
|
||||
title: 'Conv',
|
||||
created: '2024-01-01T10:00:00.000Z',
|
||||
updated: '2024-01-01T10:00:00.000Z',
|
||||
contents: [
|
||||
{
|
||||
role: Role.Assistant,
|
||||
content: 'Edit',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
artifacts: [
|
||||
{
|
||||
filePath: 'notes/test.md',
|
||||
mimeType: 'text/markdown',
|
||||
originalContent: 'old',
|
||||
updatedContent: 'new',
|
||||
artifactPath: 'Artifacts/kept.bin'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
mockFileSystemService.readBinaryFile.mockResolvedValue(new ArrayBuffer(0));
|
||||
|
||||
await service.garbageCollectArtifacts();
|
||||
|
||||
expect(mockFileSystemService.deleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log and return an Error when listing artifact files fails', async () => {
|
||||
mockFileSystemService.listFilesInDirectory.mockRejectedValue(new Error('Disk error'));
|
||||
|
||||
const result = await service.garbageCollectArtifacts();
|
||||
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(Exception.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should run garbage collection for both attachments and artifacts on conversation deletion', async () => {
|
||||
const conversation = createTestConversation('To Delete With Artifact');
|
||||
await service.saveConversation(conversation);
|
||||
|
||||
mockFileSystemService.readObjectFromFile.mockResolvedValue({
|
||||
title: 'To Delete With Artifact',
|
||||
created: '2024-01-01T10:00:00.000Z',
|
||||
updated: '2024-01-01T10:30:00.000Z',
|
||||
contents: []
|
||||
});
|
||||
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
|
||||
mockFileSystemService.listFilesInDirectory.mockResolvedValue([]);
|
||||
|
||||
await service.deleteCurrentConversation();
|
||||
|
||||
// Drain the internal deletion queue (garbage collection is queued in the background)
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockFileSystemService.listFilesInDirectory).toHaveBeenCalledWith(
|
||||
'Vaultkeeper AI/Conversations/Attachments',
|
||||
false,
|
||||
true
|
||||
);
|
||||
expect(mockFileSystemService.listFilesInDirectory).toHaveBeenCalledWith(
|
||||
'Vaultkeeper AI/Conversations/Artifacts',
|
||||
false,
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConversationTitle', () => {
|
||||
it('should move file to new path with new title', async () => {
|
||||
mockFileSystemService.moveFile.mockResolvedValue(undefined); // void = success
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { AIToolCall } from '../../AIClasses/AIToolCall';
|
|||
import { AIToolResponse } from '../../AIClasses/ToolDefinitions/AIToolResponse';
|
||||
import { AIToolResponsePayload } from '../../AIClasses/ToolDefinitions/AIToolResponsePayload';
|
||||
import type { ExecutionStep } from '../../Types/ExecutionStep';
|
||||
import { Artifact } from '../../Conversations/Artifact';
|
||||
|
||||
/**
|
||||
* UNIT TESTS - ExecutionAgent
|
||||
|
|
@ -398,6 +399,111 @@ describe('ExecutionAgent - Unit Tests', () => {
|
|||
expect(result?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should invoke onArtifactProduced for each artifact returned by a tool call', async () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const step: ExecutionStep = {
|
||||
description: 'Write file',
|
||||
instruction: 'Create new file with content'
|
||||
};
|
||||
|
||||
const artifact = new Artifact('new-note.md', 'text/markdown', '', '# New Note\n\nContent here');
|
||||
mockAIToolService.performAITool.mockResolvedValueOnce(
|
||||
new AIToolResponse(
|
||||
AITool.WriteVaultFile,
|
||||
new AIToolResponsePayload({ success: true }, [artifact]),
|
||||
'tool-1'
|
||||
)
|
||||
).mockResolvedValueOnce(
|
||||
new AIToolResponse(AITool.CompleteTask, new AIToolResponsePayload({ success: true }), 'tool-2')
|
||||
);
|
||||
|
||||
let toolCallCount = 0;
|
||||
|
||||
mockAI.streamRequest.mockImplementation(async function* () {
|
||||
toolCallCount++;
|
||||
if (toolCallCount === 1) {
|
||||
yield {
|
||||
content: 'Writing',
|
||||
toolCall: new AIToolCall(
|
||||
AITool.WriteVaultFile,
|
||||
{
|
||||
file_path: 'new-note.md',
|
||||
content: '# New Note\n\nContent here',
|
||||
user_message: 'Creating file'
|
||||
},
|
||||
'tool-1'
|
||||
),
|
||||
isComplete: true
|
||||
};
|
||||
} else {
|
||||
yield {
|
||||
content: 'Done',
|
||||
toolCall: new AIToolCall(
|
||||
AITool.CompleteTask,
|
||||
{
|
||||
success: true,
|
||||
description: 'File created successfully'
|
||||
},
|
||||
'tool-2'
|
||||
),
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
service.resolveAIProvider();
|
||||
await service.runExecutionAgent(step, callbacks);
|
||||
|
||||
expect(callbacks.onArtifactProduced).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onArtifactProduced).toHaveBeenCalledWith(artifact);
|
||||
});
|
||||
|
||||
it('should not invoke onArtifactProduced when a tool call returns no artifacts', async () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const step: ExecutionStep = {
|
||||
description: 'Search for files',
|
||||
instruction: 'Search for files with tag #important'
|
||||
};
|
||||
|
||||
let toolCallCount = 0;
|
||||
|
||||
mockAI.streamRequest.mockImplementation(async function* () {
|
||||
toolCallCount++;
|
||||
if (toolCallCount === 1) {
|
||||
yield {
|
||||
content: 'Searching',
|
||||
toolCall: new AIToolCall(
|
||||
AITool.SearchVaultFiles,
|
||||
{
|
||||
search_terms: ['#important'],
|
||||
user_message: 'Searching for tagged files'
|
||||
},
|
||||
'tool-1'
|
||||
),
|
||||
isComplete: true
|
||||
};
|
||||
} else {
|
||||
yield {
|
||||
content: 'Done',
|
||||
toolCall: new AIToolCall(
|
||||
AITool.CompleteTask,
|
||||
{
|
||||
success: true,
|
||||
description: 'Found 3 files with #important tag'
|
||||
},
|
||||
'tool-2'
|
||||
),
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
service.resolveAIProvider();
|
||||
await service.runExecutionAgent(step, callbacks);
|
||||
|
||||
expect(callbacks.onArtifactProduced).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call multiple functions in sequence', async () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const step: ExecutionStep = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue