From 6b31e3d4e98839ca36537ab0a7ae4099de8a9740 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 10 Jul 2026 21:44:40 +0100 Subject: [PATCH] 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. --- __tests__/Conversations/Artifact.test.ts | 205 +++++++++++++ __tests__/Services/AIToolService.test.ts | 228 ++++++++++++++ .../ConversationFileSystemService.test.ts | 290 +++++++++++++++++- __tests__/Services/ExecutionAgent.test.ts | 106 +++++++ 4 files changed, 828 insertions(+), 1 deletion(-) create mode 100644 __tests__/Conversations/Artifact.test.ts diff --git a/__tests__/Conversations/Artifact.test.ts b/__tests__/Conversations/Artifact.test.ts new file mode 100644 index 0000000..3e47267 --- /dev/null +++ b/__tests__/Conversations/Artifact.test.ts @@ -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); + }); + }); +}); diff --git a/__tests__/Services/AIToolService.test.ts b/__tests__/Services/AIToolService.test.ts index 3f286a6..2052032 100644 --- a/__tests__/Services/AIToolService.test.ts +++ b/__tests__/Services/AIToolService.test.ts @@ -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', () => { diff --git a/__tests__/Services/ConversationFileSystemService.test.ts b/__tests__/Services/ConversationFileSystemService.test.ts index 9085c04..94f2507 100644 --- a/__tests__/Services/ConversationFileSystemService.test.ts +++ b/__tests__/Services/ConversationFileSystemService.test.ts @@ -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 diff --git a/__tests__/Services/ExecutionAgent.test.ts b/__tests__/Services/ExecutionAgent.test.ts index 2268aa9..35b3bd4 100644 --- a/__tests__/Services/ExecutionAgent.test.ts +++ b/__tests__/Services/ExecutionAgent.test.ts @@ -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 = {