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:
Andrew Beal 2026-07-10 21:44:40 +01:00
parent 10ddb1da28
commit 6b31e3d4e9
4 changed files with 828 additions and 1 deletions

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

View file

@ -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', () => {

View file

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

View file

@ -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 = {