andy-stack_vaultkeeper-ai/__tests__/Conversations/Conversation.test.ts
Andrew Beal 10ddb1da28 feat: add artifact tracking system for agent file operations
Implement comprehensive artifact tracking to record all file modifications made by the AI agent during conversations. Add artifact persistence, garbage collection, and UI updates with new "Discuss" button styling.
2026-07-10 21:23:44 +01:00

629 lines
22 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { Conversation } from '../../Conversations/Conversation';
import { ConversationContent } from '../../Conversations/ConversationContent';
import { Role } from '../../Enums/Role';
import { AIToolResponse } from '../../AIClasses/ToolDefinitions/AIToolResponse';
import { AIToolResponsePayload } from '../../AIClasses/ToolDefinitions/AIToolResponsePayload';
import { AITool } from '../../Enums/AITool';
import { MimeType } from '../../Enums/MimeType';
import { StringTools } from '../../Helpers/StringTools';
import { Attachment } from '../../Conversations/Attachment';
describe('Conversation', () => {
describe('constructor', () => {
it('should create a new conversation with current date', () => {
const before = new Date();
const conversation = new Conversation();
const after = new Date();
expect(conversation.created.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(conversation.created.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should initialize updated date to match created date', () => {
const conversation = new Conversation();
expect(conversation.updated.getTime()).toBeCloseTo(conversation.created.getTime(), -1);
});
it('should generate title from created date', () => {
const conversation = new Conversation();
// Title should be a formatted date string
expect(conversation.title).toBeDefined();
expect(conversation.title).toMatch(/^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/);
});
it('should initialize contents as empty array', () => {
const conversation = new Conversation();
expect(conversation.contents).toEqual([]);
expect(Array.isArray(conversation.contents)).toBe(true);
});
});
describe('isConversationData', () => {
it('should return true for valid conversation data', () => {
const validData = {
title: 'Test Conversation',
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z',
contents: []
};
expect(Conversation.isConversationData(validData)).toBe(true);
});
it('should return true for valid conversation data with contents', () => {
const validData = {
title: 'Test Conversation',
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z',
contents: [
{
role: 'user',
content: 'Hello',
promptContent: '',
toolCall: '',
timestamp: '2024-01-01T00:00:00.000Z',
isToolCall: false,
isToolCallResponse: false,
isProviderSpecificContent: false
}
]
};
expect(Conversation.isConversationData(validData)).toBe(true);
});
it('should return false when data is null', () => {
expect(Conversation.isConversationData(null)).toBe(false);
});
it('should return false when data is not an object', () => {
expect(Conversation.isConversationData('string')).toBe(false);
expect(Conversation.isConversationData(123)).toBe(false);
expect(Conversation.isConversationData(true)).toBe(false);
expect(Conversation.isConversationData([])).toBe(false);
});
it('should return false when title is missing', () => {
const invalidData = {
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z',
contents: []
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when title is not a string', () => {
const invalidData = {
title: 123,
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z',
contents: []
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when created is missing', () => {
const invalidData = {
title: 'Test',
updated: '2024-01-01T00:00:00.000Z',
contents: []
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when created is not a string', () => {
const invalidData = {
title: 'Test',
created: new Date(),
updated: '2024-01-01T00:00:00.000Z',
contents: []
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when updated is missing', () => {
const invalidData = {
title: 'Test',
created: '2024-01-01T00:00:00.000Z',
contents: []
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when updated is not a string', () => {
const invalidData = {
title: 'Test',
created: '2024-01-01T00:00:00.000Z',
updated: 123,
contents: []
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when contents is missing', () => {
const invalidData = {
title: 'Test',
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z'
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when contents is not an array', () => {
const invalidData = {
title: 'Test',
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z',
contents: 'not an array'
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when contents contains invalid data', () => {
const invalidData = {
title: 'Test',
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z',
contents: [
{ invalid: 'data' }
]
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
it('should return false when some contents are valid and some are invalid', () => {
const invalidData = {
title: 'Test',
created: '2024-01-01T00:00:00.000Z',
updated: '2024-01-01T00:00:00.000Z',
contents: [
{
role: 'user',
content: 'Hello',
promptContent: '',
toolCall: '',
timestamp: '2024-01-01T00:00:00.000Z',
isToolCall: false,
isToolCallResponse: false,
isProviderSpecificContent: false
},
{ invalid: 'data' }
]
};
expect(Conversation.isConversationData(invalidData)).toBe(false);
});
});
describe('content manipulation', () => {
it('should update content of most recent conversation content', () => {
const conversation = new Conversation();
const content = new ConversationContent({ role: Role.User, content: 'initial' });
conversation.contents.push(content);
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.content = 'updated';
expect(conversation.contents[0].content).toBe('updated');
});
it('should only update the last content when multiple contents exist', () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent({ role: Role.User, content: 'first' }));
conversation.contents.push(new ConversationContent({ role: Role.Assistant, content: 'second' }));
conversation.contents.push(new ConversationContent({ role: Role.User, content: 'third' }));
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.content = 'modified';
expect(conversation.contents[0].content).toBe('first');
expect(conversation.contents[1].content).toBe('second');
expect(conversation.contents[2].content).toBe('modified');
});
it('should do nothing when contents array is empty', () => {
const conversation = new Conversation();
// Should not throw error
expect(() => {
const mostRecent = conversation.contents[conversation.contents.length - 1];
if (mostRecent) {
mostRecent.content = 'test';
}
}).not.toThrow();
expect(conversation.contents).toHaveLength(0);
});
it('should handle empty string as new content', () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent({ role: Role.User, content: 'initial' }));
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.content = '';
expect(conversation.contents[0].content).toBe('');
});
it('should handle multiline content', () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent({ role: Role.User, content: 'initial' }));
const multilineContent = 'Line 1\nLine 2\nLine 3';
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.content = multilineContent;
expect(conversation.contents[0].content).toBe(multilineContent);
});
});
describe('function call manipulation', () => {
it('should set function call on most recent content', () => {
const conversation = new Conversation();
const content = new ConversationContent({ role: Role.Assistant });
conversation.contents.push(content);
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.toolCall = 'readFile';
expect(conversation.contents[0].toolCall).toBe('readFile');
});
it('should mark most recent content as function call', () => {
const conversation = new Conversation();
const content = new ConversationContent({ role: Role.Assistant });
conversation.contents.push(content);
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.toolCall = 'readFile';
expect(conversation.contents[0].toolCall).toBe('readFile');
});
it('should only update the last content when multiple contents exist', () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent({ role: Role.User }));
conversation.contents.push(new ConversationContent({ role: Role.Assistant }));
conversation.contents.push(new ConversationContent({ role: Role.Assistant }));
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.toolCall = 'searchFiles';
expect(conversation.contents[0].toolCall).toBeUndefined();
expect(conversation.contents[1].toolCall).toBeUndefined();
expect(conversation.contents[2].toolCall).toBe('searchFiles');
});
it('should do nothing when contents array is empty', () => {
const conversation = new Conversation();
// Should not throw error
expect(() => {
const mostRecent = conversation.contents[conversation.contents.length - 1];
if (mostRecent) {
mostRecent.toolCall = 'test';
}
}).not.toThrow();
expect(conversation.contents).toHaveLength(0);
});
it('should handle empty string as function call', () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent({ role: Role.Assistant }));
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.toolCall = '';
expect(conversation.contents[0].toolCall).toBe('');
});
it('should overwrite existing function call', () => {
const conversation = new Conversation();
const content = new ConversationContent({ role: Role.Assistant, toolCall: 'oldFunction' });
conversation.contents.push(content);
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.toolCall = 'newFunction';
expect(conversation.contents[0].toolCall).toBe('newFunction');
});
});
describe('integration', () => {
it('should handle complete conversation workflow', () => {
const conversation = new Conversation();
// Add user message
const userMessage = new ConversationContent({ role: Role.User, content: 'Hello' });
conversation.contents.push(userMessage);
// Add assistant response
const assistantMessage = new ConversationContent({ role: Role.Assistant });
conversation.contents.push(assistantMessage);
// Stream in assistant response
const mostRecent = conversation.contents[conversation.contents.length - 1];
mostRecent.content = 'Hi there';
mostRecent.content = 'Hi there, how can I help you?';
// Assistant makes a function call
mostRecent.toolCall = 'readFile';
expect(conversation.contents).toHaveLength(2);
expect(conversation.contents[0].role).toBe(Role.User);
expect(conversation.contents[0].content).toBe('Hello');
expect(conversation.contents[1].role).toBe(Role.Assistant);
expect(conversation.contents[1].content).toBe('Hi there, how can I help you?');
expect(conversation.contents[1].toolCall).toBe('readFile');
});
it('should maintain conversation metadata', () => {
const conversation = new Conversation();
expect(conversation.title).toBeDefined();
expect(conversation.created).toBeInstanceOf(Date);
expect(conversation.updated).toBeInstanceOf(Date);
expect(conversation.contents).toEqual([]);
});
});
describe('addFunctionResponse', () => {
describe('read_vault_files with errors', () => {
it('should handle file not found errors without creating attachments', () => {
const conversation = new Conversation();
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload({
results: [
{ path: 'nonexistent.md', error: 'File does not exist: nonexistent.md' }
]
}),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
// Should have one content item (the function response with error)
expect(conversation.contents).toHaveLength(1);
expect(conversation.contents[0].role).toBe(Role.User);
expect(conversation.contents[0].shouldDisplayContent).toBe(false);
// Should not have attachments
expect(conversation.contents[0].attachments).toHaveLength(0);
// Parse the function response to verify error is included
const parsedResponse = JSON.parse(conversation.contents[0].functionResponse!);
expect(parsedResponse.functionResponse.response.result.results).toHaveLength(1);
expect(parsedResponse.functionResponse.response.result.results[0].error).toBe('File does not exist: nonexistent.md');
});
it('should handle mix of successful reads and errors', () => {
const conversation = new Conversation();
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload({
results: [
{ path: 'existing.md', type: 'md', contents: '# Hello' },
{ path: 'nonexistent.md', error: 'File does not exist: nonexistent.md' },
{ path: 'another.txt', type: 'txt', contents: 'World' }
]
}),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
// Should have one content item with all results
expect(conversation.contents).toHaveLength(1);
expect(conversation.contents[0].role).toBe(Role.User);
// Parse the function response
const parsedResponse = JSON.parse(conversation.contents[0].functionResponse!);
const results = parsedResponse.functionResponse.response.result.results;
// Should have all 3 results (2 successful + 1 error)
expect(results).toHaveLength(3);
// Check that we have the expected content (order may vary)
const textResults = results.filter((r: { contents?: string }) => r.contents);
const errorResult = results.filter((r: { error?: string }) => r.error);
expect(textResults).toHaveLength(2);
expect(textResults.some((r: { contents: string }) => r.contents === '# Hello')).toBe(true);
expect(textResults.some((r: { contents: string }) => r.contents === 'World')).toBe(true);
expect(errorResult).toHaveLength(1);
expect(errorResult[0].error).toBe('File does not exist: nonexistent.md');
});
it('should not create attachments for error results with binary file extensions', () => {
const conversation = new Conversation();
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload({
results: [
{ path: 'image.png', error: 'File does not exist: image.png' }
]
}),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
// Should have one content item (function response only)
expect(conversation.contents).toHaveLength(1);
// Should not have attachments (error result should not be treated as binary file)
expect(conversation.contents[0].attachments).toHaveLength(0);
// Verify error is in the function response
const parsedResponse = JSON.parse(conversation.contents[0].functionResponse!);
expect(parsedResponse.functionResponse.response.result.results[0].error).toBeDefined();
});
it('should handle binary files with errors not creating invalid attachments', () => {
const conversation = new Conversation();
const validAttachment = new Attachment('valid.png', MimeType.IMAGE_PNG, 'base64encodeddata...');
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ results: [{ path: 'image.png', error: 'File does not exist: image.png' }] },
[],
[validAttachment]
),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
// Should have 2 content items: function response + attachments
expect(conversation.contents).toHaveLength(2);
// First item should have the error
const parsedResponse = JSON.parse(conversation.contents[0].functionResponse!);
expect(parsedResponse.functionResponse.response.result.results).toHaveLength(1);
expect(parsedResponse.functionResponse.response.result.results[0].error).toBe('File does not exist: image.png');
// Second item should only have the valid attachment
expect(conversation.contents[1].attachments).toHaveLength(1);
expect(conversation.contents[1].attachments[0].fileName).toBe('valid.png');
});
});
describe('read_vault_files without errors', () => {
it('should handle successful text file reads', () => {
const conversation = new Conversation();
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload({
results: [
{ path: 'file.md', type: 'md', contents: '# Title' }
]
}),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
expect(conversation.contents).toHaveLength(1);
const parsedResponse = JSON.parse(conversation.contents[0].functionResponse!);
expect(parsedResponse.functionResponse.response.result.results[0].contents).toBe('# Title');
});
it('should create attachments for binary files', () => {
const conversation = new Conversation();
const attachment = new Attachment('image.png', MimeType.IMAGE_PNG, 'base64data...');
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
[],
[attachment]
),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
// Should have 2 items: function response + attachments
expect(conversation.contents).toHaveLength(2);
expect(conversation.contents[1].attachments).toHaveLength(1);
expect(conversation.contents[1].attachments[0].fileName).toBe('image.png');
});
});
describe('read_vault_files with document files', () => {
it('should base64-encode document file contents and set mime type to text/plain', () => {
const conversation = new Conversation();
const documentText = 'This is the extracted text from a document file.';
const docAttachment = new Attachment('report.odt', MimeType.TEXT_PLAIN, StringTools.toBase64(documentText));
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
[],
[docAttachment]
),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
// Should have 2 items: function response + attachments
expect(conversation.contents).toHaveLength(2);
expect(conversation.contents[1].attachments).toHaveLength(1);
const attachment = conversation.contents[1].attachments[0];
expect(attachment.fileName).toBe('report.odt');
expect(attachment.mimeType).toBe(MimeType.TEXT_PLAIN);
// Content should be base64-encoded, not plain text
expect(attachment.base64).toBe(StringTools.toBase64(documentText));
expect(attachment.base64).not.toBe(documentText);
});
it('should handle document files alongside text and binary files', () => {
const conversation = new Conversation();
const xlsxAttachment = new Attachment('spreadsheet.xlsx', MimeType.TEXT_PLAIN, StringTools.toBase64('Extracted spreadsheet text'));
const imgAttachment = new Attachment('photo.png', MimeType.IMAGE_PNG, 'base64imagedata...');
const functionResponse = new AIToolResponse(
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ results: [{ type: 'md', path: 'notes.md', contents: '# Notes' }], message: 'The contents of the files are included below.' },
[],
[xlsxAttachment, imgAttachment]
),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
// Should have 2 items: function response (text) + attachments (document + binary)
expect(conversation.contents).toHaveLength(2);
// Text file in function response
const parsedResponse = JSON.parse(conversation.contents[0].functionResponse!);
expect(parsedResponse.functionResponse.response.result.results).toHaveLength(1);
expect(parsedResponse.functionResponse.response.result.results[0].path).toBe('notes.md');
// Document and binary as attachments
const attachments = conversation.contents[1].attachments;
expect(attachments).toHaveLength(2);
const docAttachment = attachments.find(a => a.fileName === 'spreadsheet.xlsx')!;
expect(docAttachment.mimeType).toBe(MimeType.TEXT_PLAIN);
expect(docAttachment.base64).toBe(StringTools.toBase64('Extracted spreadsheet text'));
const pngAttachment = attachments.find(a => a.fileName === 'photo.png')!;
expect(pngAttachment.base64).toBe('base64imagedata...');
});
});
describe('non-read_vault_files functions', () => {
it('should handle other functions normally', () => {
const conversation = new Conversation();
const functionResponse = new AIToolResponse(
AITool.WriteVaultFile,
new AIToolResponsePayload({ success: true }),
'tool-123'
);
conversation.addFunctionResponse(functionResponse);
expect(conversation.contents).toHaveLength(1);
expect(conversation.contents[0].role).toBe(Role.User);
expect(conversation.contents[0].attachments).toHaveLength(0);
});
});
});
});