UNPKG

@aj-archipelago/cortex

Version:

Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.

1,271 lines (1,086 loc) 146 kB
// fileCollection.test.js // Integration tests for file collection tool import test from 'ava'; import serverFactory from '../../../../index.js'; import { callPathway } from '../../../../lib/pathwayTools.js'; import { generateFileMessageContent, resolveFileParameter, loadFileCollection, syncAndStripFilesFromChatHistory } from '../../../../lib/fileUtils.js'; // Helper to create agentContext from contextId/contextKey const createAgentContext = (contextId, contextKey = null) => [{ contextId, contextKey, default: true }]; let testServer; test.before(async () => { const { server, startServer } = await serverFactory(); if (startServer) { await startServer(); } testServer = server; }); test.after.always('cleanup', async () => { if (testServer) { await testServer.stop(); } }); // Helper to create a test context const createTestContext = () => { const contextId = `test-file-collection-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; return contextId; }; // Helper to clean up test data const cleanup = async (contextId, contextKey = null) => { try { const { getRedisClient } = await import('../../../../lib/fileUtils.js'); const redisClient = await getRedisClient(); if (redisClient) { const contextMapKey = `FileStoreMap:ctx:${contextId}`; await redisClient.del(contextMapKey); } } catch (e) { // Ignore cleanup errors } }; test('File collection: Add file to collection', async t => { const contextId = createTestContext(); try { const result = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/test.jpg', gcs: 'gs://bucket/test.jpg', filename: 'test.jpg', tags: ['photo', 'test'], notes: 'Test file', userMessage: 'Adding test file' }); const parsed = JSON.parse(result); t.is(parsed.success, true); t.truthy(parsed.fileId); t.true(parsed.message.includes('test.jpg')); // Verify it was saved to Redis hash map const collection = await loadFileCollection(contextId, { useCache: false }); t.is(collection.length, 1); t.is(collection[0].displayFilename, 'test.jpg'); t.is(collection[0].url, 'https://example.com/test.jpg'); t.is(collection[0].gcs, 'gs://bucket/test.jpg'); t.deepEqual(collection[0].tags, ['photo', 'test']); t.is(collection[0].notes, 'Test file'); } finally { await cleanup(contextId); } }); test('File collection: List files', async t => { const contextId = createTestContext(); try { // Add a few files first await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file1.jpg', filename: 'file1.jpg', userMessage: 'Add file 1' }); await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file2.pdf', filename: 'file2.pdf', tags: ['document'], userMessage: 'Add file 2' }); // List files const result = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], userMessage: 'List files' }); const parsed = JSON.parse(result); t.is(parsed.success, true); t.is(parsed.count, 2); t.is(parsed.totalFiles, 2); t.is(parsed.files.length, 2); t.true(parsed.files.some(f => f.displayFilename === 'file1.jpg')); t.true(parsed.files.some(f => f.displayFilename === 'file2.pdf')); } finally { await cleanup(contextId); } }); test('File collection: Search files', async t => { const contextId = createTestContext(); try { // Add files with different metadata await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/report.pdf', filename: 'report.pdf', tags: ['document', 'report'], notes: 'Monthly report', userMessage: 'Add report' }); await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/image.jpg', filename: 'image.jpg', tags: ['photo'], notes: 'Photo of office', userMessage: 'Add image' }); // Search by filename const result1 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], query: 'report', userMessage: 'Search for report' }); const parsed1 = JSON.parse(result1); t.is(parsed1.success, true); t.is(parsed1.count, 1); t.is(parsed1.files[0].displayFilename, 'report.pdf'); // Search by tag const result2 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], query: 'photo', userMessage: 'Search for photo' }); const parsed2 = JSON.parse(result2); t.is(parsed2.success, true); t.is(parsed2.count, 1); t.is(parsed2.files[0].displayFilename, 'image.jpg'); // Search by notes const result3 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], query: 'office', userMessage: 'Search for office' }); const parsed3 = JSON.parse(result3); t.is(parsed3.success, true); t.is(parsed3.count, 1); t.is(parsed3.files[0].displayFilename, 'image.jpg'); } finally { await cleanup(contextId); } }); test('File collection: Search by filename when displayFilename not set', async t => { const contextId = createTestContext(); try { // Add file with only filename (no displayFilename) // This tests the bug fix where search only checked displayFilename await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/smoketest-tools.txt', filename: 'smoketest-tools.txt', tags: ['smoketest', 'text'], notes: 'Created to test SearchFileCollection', userMessage: 'Add smoketest file' }); // Search by filename - should find it even if displayFilename not set const result1 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], query: 'smoketest', userMessage: 'Search for smoketest' }); const parsed1 = JSON.parse(result1); t.is(parsed1.success, true); t.is(parsed1.count, 1); t.true(parsed1.files[0].displayFilename === 'smoketest-tools.txt' || parsed1.files[0].filename === 'smoketest-tools.txt'); // Search by full filename const result2 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], query: 'smoketest-tools', userMessage: 'Search for smoketest-tools' }); const parsed2 = JSON.parse(result2); t.is(parsed2.success, true); t.is(parsed2.count, 1); } finally { await cleanup(contextId); } }); test('File collection: Remove single file', async t => { const contextId = createTestContext(); try { // Add files const addResult1 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file1.jpg', filename: 'file1.jpg', userMessage: 'Add file 1' }); const file1Id = JSON.parse(addResult1).fileId; await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file2.pdf', filename: 'file2.pdf', userMessage: 'Add file 2' }); // Remove file1 const result = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], fileIds: [file1Id], userMessage: 'Remove file 1' }); const parsed = JSON.parse(result); t.is(parsed.success, true); t.is(parsed.removedCount, 1); t.is(parsed.remainingFiles, 1); t.is(parsed.removedFiles.length, 1); t.is(parsed.removedFiles[0].displayFilename, 'file1.jpg'); t.true(parsed.message.includes('Cloud storage cleanup started in background')); // Verify it was removed (cache should be invalidated immediately) const listResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], userMessage: 'List files' }); const listParsed = JSON.parse(listResult); t.is(listParsed.totalFiles, 1); // Check displayFilename with fallback to filename t.false(listParsed.files.some(f => (f.displayFilename || f.filename) === 'file1.jpg')); } finally { await cleanup(contextId); } }); test('File collection: Remove file - cache invalidation', async t => { const contextId = createTestContext(); try { // Add files const addResult1 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file1.jpg', filename: 'file1.jpg', userMessage: 'Add file 1' }); const file1Id = JSON.parse(addResult1).fileId; const addResult2 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file2.pdf', filename: 'file2.pdf', userMessage: 'Add file 2' }); const file2Id = JSON.parse(addResult2).fileId; // Verify both files are in collection const listBefore = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], userMessage: 'List files before removal' }); const listBeforeParsed = JSON.parse(listBefore); t.is(listBeforeParsed.totalFiles, 2); // Remove file1 const removeResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], fileIds: [file1Id], userMessage: 'Remove file 1' }); const removeParsed = JSON.parse(removeResult); t.is(removeParsed.success, true); t.is(removeParsed.removedCount, 1); // Immediately list files - should reflect removal (cache invalidation test) const listAfter = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], userMessage: 'List files after removal' }); const listAfterParsed = JSON.parse(listAfter); t.is(listAfterParsed.totalFiles, 1, 'List should immediately reflect removal (cache invalidated)'); t.false(listAfterParsed.files.some(f => (f.displayFilename || f.filename) === 'file1.jpg')); t.true(listAfterParsed.files.some(f => (f.displayFilename || f.filename) === 'file2.pdf')); } finally { await cleanup(contextId); } }); test('File collection: Remove multiple files', async t => { const contextId = createTestContext(); try { // Add files const addResult1 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file1.jpg', filename: 'file1.jpg', userMessage: 'Add file 1' }); const file1Id = JSON.parse(addResult1).fileId; const addResult2 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file2.pdf', filename: 'file2.pdf', userMessage: 'Add file 2' }); const file2Id = JSON.parse(addResult2).fileId; // Remove multiple files const result = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], fileIds: [file1Id, file2Id], userMessage: 'Remove files 1 and 2' }); const parsed = JSON.parse(result); t.is(parsed.success, true); t.is(parsed.removedCount, 2); t.is(parsed.remainingFiles, 0); t.is(parsed.removedFiles.length, 2); t.true(parsed.message.includes('Cloud storage cleanup started in background')); // Verify collection is empty const listResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], userMessage: 'List files' }); const listParsed = JSON.parse(listResult); t.is(listParsed.totalFiles, 0); } finally { await cleanup(contextId); } }); test('File collection: Error handling - missing contextId', async t => { try { const result = await callPathway('sys_tool_file_collection', { url: 'https://example.com/test.jpg', filename: 'test.jpg', userMessage: 'Test' }); // Result might be JSON or error string let parsed; try { parsed = JSON.parse(result); } catch { // If not JSON, it's an error string - that's fine t.true(typeof result === 'string'); t.true(result.includes('required') || result.includes('agentContext') || result.includes('contextId')); return; } // If it's JSON, check for error if (parsed.success === false) { t.true(parsed.error.includes('required') || parsed.error.includes('agentContext') || parsed.error.includes('contextId')); } else { // If no error, that's also a failure case t.fail('Expected error when contextId is missing'); } } catch (error) { // Error thrown is also acceptable t.true(error.message.includes('required') || error.message.includes('agentContext') || error.message.includes('contextId') || error.message.includes('EADDRINUSE')); } }); test('File collection: Error handling - remove non-existent file', async t => { const contextId = createTestContext(); try { const result = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], fileIds: ['non-existent-id'], userMessage: 'Remove file' }); const parsed = JSON.parse(result); t.is(parsed.success, false); t.true(parsed.error.includes('No files found matching')); } finally { await cleanup(contextId); } }); test('File collection: List with filters and sorting', async t => { const contextId = createTestContext(); try { // Add files with different tags and dates await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file1.jpg', filename: 'a_file.jpg', tags: ['photo'], userMessage: 'Add file 1' }); // Wait a bit to ensure different timestamps await new Promise(resolve => setTimeout(resolve, 10)); await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/file2.pdf', filename: 'z_file.pdf', tags: ['document'], userMessage: 'Add file 2' }); // List sorted by filename const result1 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], sortBy: 'filename', userMessage: 'List sorted by filename' }); const parsed1 = JSON.parse(result1); t.is(parsed1.files[0].displayFilename, 'a_file.jpg'); t.is(parsed1.files[1].displayFilename, 'z_file.pdf'); // List filtered by tag const result2 = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], tags: ['photo'], userMessage: 'List photos' }); const parsed2 = JSON.parse(result2); t.is(parsed2.count, 1); t.is(parsed2.files[0].displayFilename, 'a_file.jpg'); } finally { await cleanup(contextId); } }); // Test generateFileMessageContent function (integration tests) // Note: These tests verify basic functionality. If WHISPER_MEDIA_API_URL is configured, // generateFileMessageContent will automatically use short-lived URLs when file hashes are available. test('generateFileMessageContent should find file by ID', async t => { const contextId = createTestContext(); try { // Add a file to collection await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/test.pdf', gcs: 'gs://bucket/test.pdf', filename: 'test.pdf', userMessage: 'Add test file' }); // Get the file ID from the collection const collection = await loadFileCollection(contextId, { useCache: false }); const fileId = collection[0].id; // Normalize by ID const result = await generateFileMessageContent(fileId, createAgentContext(contextId)); t.truthy(result); t.is(result.type, 'image_url'); t.is(result.url, 'https://example.com/test.pdf'); t.is(result.gcs, 'gs://bucket/test.pdf'); // originalFilename is no longer returned in message content objects t.truthy(result.url); t.truthy(result.hash); } finally { await cleanup(contextId); } }); test('generateFileMessageContent should find file by URL', async t => { const contextId = createTestContext(); try { // Add a file to collection await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/test.pdf', gcs: 'gs://bucket/test.pdf', filename: 'test.pdf', userMessage: 'Add test file' }); // Normalize by URL const result = await generateFileMessageContent('https://example.com/test.pdf', createAgentContext(contextId)); t.truthy(result); t.is(result.url, 'https://example.com/test.pdf'); t.is(result.gcs, 'gs://bucket/test.pdf'); } finally { await cleanup(contextId); } }); test('generateFileMessageContent should find file by fuzzy filename match', async t => { const contextId = createTestContext(); try { // Add files to collection await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/document.pdf', filename: 'document.pdf', userMessage: 'Add document' }); await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/image.jpg', filename: 'image.jpg', userMessage: 'Add image' }); // Normalize by partial filename const result1 = await generateFileMessageContent('document', createAgentContext(contextId)); t.truthy(result1); // originalFilename is no longer returned in message content objects t.truthy(result1.url); t.truthy(result1.hash); // Normalize by full filename const result2 = await generateFileMessageContent('image.jpg', createAgentContext(contextId)); t.truthy(result2); // originalFilename is no longer returned in message content objects t.truthy(result2.url); t.truthy(result2.hash); } finally { await cleanup(contextId); } }); test('generateFileMessageContent should detect image type', async t => { const contextId = createTestContext(); try { // Add an image file await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/image.jpg', filename: 'image.jpg', userMessage: 'Add image' }); const collection = await loadFileCollection(contextId, { useCache: false }); const fileId = collection[0].id; const result = await generateFileMessageContent(fileId, createAgentContext(contextId)); t.truthy(result); t.is(result.type, 'image_url'); t.is(result.url, 'https://example.com/image.jpg'); } finally { await cleanup(contextId); } }); // Tests for resolveFileParameter test('resolveFileParameter: Resolve by file ID', async t => { const contextId = createTestContext(); try { // Add a file to collection const addResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/test-doc.pdf', gcs: 'gs://bucket/test-doc.pdf', filename: 'test-doc.pdf', userMessage: 'Adding test file' }); const addParsed = JSON.parse(addResult); const fileId = addParsed.fileId; // Resolve by file ID const resolved = await resolveFileParameter(fileId, createAgentContext(contextId)); t.is(resolved, 'https://example.com/test-doc.pdf'); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Resolve by filename', async t => { const contextId = createTestContext(); try { // Add a file to collection await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/my-file.txt', gcs: 'gs://bucket/my-file.txt', filename: 'my-file.txt', userMessage: 'Adding test file' }); // Resolve by filename const resolved = await resolveFileParameter('my-file.txt', createAgentContext(contextId)); t.is(resolved, 'https://example.com/my-file.txt'); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Resolve by hash', async t => { const contextId = createTestContext(); const testHash = 'abc123def456'; try { // Add a file to collection with hash await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/hashed-file.jpg', gcs: 'gs://bucket/hashed-file.jpg', filename: 'hashed-file.jpg', hash: testHash, userMessage: 'Adding test file' }); // Resolve by hash const resolved = await resolveFileParameter(testHash, createAgentContext(contextId)); t.is(resolved, 'https://example.com/hashed-file.jpg'); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Resolve by Azure URL', async t => { const contextId = createTestContext(); const testUrl = 'https://example.com/existing-file.pdf'; try { // Add a file to collection await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: testUrl, gcs: 'gs://bucket/existing-file.pdf', filename: 'existing-file.pdf', userMessage: 'Adding test file' }); // Resolve by Azure URL const resolved = await resolveFileParameter(testUrl, createAgentContext(contextId)); t.is(resolved, testUrl); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Resolve by GCS URL', async t => { const contextId = createTestContext(); const testGcsUrl = 'gs://bucket/gcs-file.pdf'; try { // Add a file to collection await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/gcs-file.pdf', gcs: testGcsUrl, filename: 'gcs-file.pdf', userMessage: 'Adding test file' }); // Resolve by GCS URL const resolved = await resolveFileParameter(testGcsUrl, createAgentContext(contextId)); t.is(resolved, 'https://example.com/gcs-file.pdf'); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Prefer GCS URL when preferGcs is true', async t => { const contextId = createTestContext(); const testGcsUrl = 'gs://bucket/prefer-gcs-file.pdf'; const testAzureUrl = 'https://example.com/prefer-gcs-file.pdf'; try { // Add a file to collection with both URLs await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: testAzureUrl, gcs: testGcsUrl, filename: 'prefer-gcs-file.pdf', userMessage: 'Adding test file' }); // Resolve by filename without preferGcs (should return Azure URL) const resolvedDefault = await resolveFileParameter('prefer-gcs-file.pdf', createAgentContext(contextId)); t.is(resolvedDefault, testAzureUrl); // Resolve by filename with preferGcs (should return GCS URL) const resolvedGcs = await resolveFileParameter('prefer-gcs-file.pdf', createAgentContext(contextId), { preferGcs: true }); t.is(resolvedGcs, testGcsUrl); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Return null when file not found', async t => { const contextId = createTestContext(); try { // Try to resolve a non-existent file const resolved = await resolveFileParameter('non-existent-file.txt', createAgentContext(contextId)); t.is(resolved, null); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Return null when contextId is missing', async t => { // Try to resolve without contextId const resolved = await resolveFileParameter('some-file.txt', null); t.is(resolved, null); }); test('resolveFileParameter: Return null when fileParam is empty', async t => { const contextId = createTestContext(); try { // Try with empty string const resolved1 = await resolveFileParameter('', createAgentContext(contextId)); t.is(resolved1, null); // Try with null const resolved2 = await resolveFileParameter(null, createAgentContext(contextId)); t.is(resolved2, null); // Try with undefined const resolved3 = await resolveFileParameter(undefined, createAgentContext(contextId)); t.is(resolved3, null); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Contains match on filename', async t => { const contextId = createTestContext(); try { // Add a file with a specific filename await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/my-document.pdf', gcs: 'gs://bucket/my-document.pdf', filename: 'my-document.pdf', userMessage: 'Adding test file' }); // Resolve by partial filename (contains match) const resolved = await resolveFileParameter('document.pdf', createAgentContext(contextId)); t.is(resolved, 'https://example.com/my-document.pdf'); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Contains match requires minimum 4 characters', async t => { const contextId = createTestContext(); try { // Add a file with a specific filename await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/test.pdf', gcs: 'gs://bucket/test.pdf', filename: 'test.pdf', userMessage: 'Adding test file' }); // Try to resolve with a 3-character parameter (should fail - too short) const resolvedShort = await resolveFileParameter('pdf', createAgentContext(contextId)); t.is(resolvedShort, null, 'Should not match with parameter shorter than 4 characters'); // Try to resolve with a 4-character parameter (should succeed) const resolvedLong = await resolveFileParameter('test', createAgentContext(contextId)); t.is(resolvedLong, 'https://example.com/test.pdf', 'Should match with parameter 4+ characters'); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Fallback to Azure URL when GCS not available and preferGcs is true', async t => { const contextId = createTestContext(); const testAzureUrl = 'https://example.com/no-gcs-file.pdf'; try { // Add a file without GCS URL await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: testAzureUrl, filename: 'no-gcs-file.pdf', userMessage: 'Adding test file' }); // Resolve with preferGcs=true, but no GCS available (should fallback to Azure URL) const resolved = await resolveFileParameter('no-gcs-file.pdf', createAgentContext(contextId), { preferGcs: true }); t.is(resolved, testAzureUrl); } finally { await cleanup(contextId); } }); test('resolveFileParameter: Handle contextKey for encrypted collections', async t => { const contextId = createTestContext(); const contextKey = 'test-encryption-key'; try { // Add a file to collection with contextKey await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], contextKey, url: 'https://example.com/encrypted-file.pdf', gcs: 'gs://bucket/encrypted-file.pdf', filename: 'encrypted-file.pdf', userMessage: 'Adding test file' }); // Resolve with contextKey const resolved = await resolveFileParameter('encrypted-file.pdf', createAgentContext(contextId, contextKey)); t.is(resolved, 'https://example.com/encrypted-file.pdf'); } finally { await cleanup(contextId); } }); test('File collection: Update file metadata', async t => { const contextId = createTestContext(); try { // Add a file first const addResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/original.pdf', filename: 'original.pdf', tags: ['initial'], notes: 'Initial notes', userMessage: 'Add file' }); const addParsed = JSON.parse(addResult); t.is(addParsed.success, true); const fileId = addParsed.fileId; // Get the hash from the collection const collection = await loadFileCollection(contextId, { useCache: false }); const file = collection.find(f => f.id === fileId); t.truthy(file); const hash = file.hash; // Update metadata using updateFileMetadata const { updateFileMetadata } = await import('../../../../lib/fileUtils.js'); const success = await updateFileMetadata(contextId, hash, { displayFilename: 'renamed.pdf', tags: ['updated', 'document'], notes: 'Updated notes', permanent: true }); t.is(success, true); // Verify metadata was updated const updatedCollection = await loadFileCollection(contextId, { useCache: false }); const updatedFile = updatedCollection.find(f => f.id === fileId); t.truthy(updatedFile); t.is(updatedFile.displayFilename, 'renamed.pdf'); t.deepEqual(updatedFile.tags, ['updated', 'document']); t.is(updatedFile.notes, 'Updated notes'); t.is(updatedFile.permanent, true); // Verify CFH fields were preserved t.is(updatedFile.url, 'https://example.com/original.pdf'); t.is(updatedFile.hash, hash); } finally { await cleanup(contextId); } }); test('updateFileMetadata should allow updating inCollection', async (t) => { const contextId = `test-${Date.now()}`; try { // Add a file to collection const addResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/test-incollection.pdf', filename: 'test-incollection.pdf', userMessage: 'Add file' }); const addParsed = JSON.parse(addResult); t.is(addParsed.success, true); const fileId = addParsed.fileId; // Get the hash from the collection const collection = await loadFileCollection(contextId, { useCache: false }); const file = collection.find(f => f.id === fileId); t.truthy(file); const hash = file.hash; // Verify file is in collection (should be global by default) t.truthy(file); // Update inCollection to a specific chat const { updateFileMetadata } = await import('../../../../lib/fileUtils.js'); const success1 = await updateFileMetadata(contextId, hash, { inCollection: ['chat-123'] }); t.is(success1, true); // Verify file is now only in chat-123 (not global) // With new unified loadFileCollection, loading without chatIds returns ALL files // So we need to check that the file's inCollection is ['chat-123'] not ['*'] const collection1 = await loadFileCollection(contextId, { useCache: false }); const file1 = collection1.find(f => f.id === fileId); // File should still appear (all files are returned), but inCollection should be ['chat-123'] t.truthy(file1); t.deepEqual(file1.inCollection, ['chat-123'], 'File should be in chat-123 collection, not global'); // Should appear when filtering by chat-123 const collection2 = await loadFileCollection(contextId, { chatIds: ['chat-123'], useCache: false }); const file2 = collection2.find(f => f.id === fileId); t.truthy(file2); // Update inCollection back to global const success2 = await updateFileMetadata(contextId, hash, { inCollection: ['*'] }); t.is(success2, true); // Verify file is back in global collection const collection3 = await loadFileCollection(contextId, { useCache: false }); const file3 = collection3.find(f => f.id === fileId); t.truthy(file3); // Verify it's global t.true( file3.inCollection === true || (Array.isArray(file3.inCollection) && file3.inCollection.includes('*')), 'File should be global' ); // Update inCollection to false (remove from collection) // false is normalized to undefined, which means "not in collection" const success3 = await updateFileMetadata(contextId, hash, { inCollection: false }); t.is(success3, true); // Verify file still exists but inCollection is undefined (not in collection) // When loading without chatIds, undefined files are included (Labeeb uploads, etc.) const collection4 = await loadFileCollection(contextId, { useCache: false }); const file4 = collection4.find(f => f.id === fileId); t.truthy(file4, 'File should still exist (false → undefined, treated same as Labeeb uploads)'); t.falsy(file4.inCollection, 'File should have undefined inCollection (not in collection)'); // Not in chat-specific collection (undefined files don't match any chatId) const collection5 = await loadFileCollection(contextId, { chatIds: ['chat-123'], useCache: false }); const file5 = collection5.find(f => f.id === fileId); t.falsy(file5, 'File should not appear when filtering by chatId (undefined = not in collection)'); } finally { await cleanup(contextId); } }); test('File collection: Permanent files not deleted on remove', async t => { const contextId = createTestContext(); try { // Add a permanent file const addResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], url: 'https://example.com/permanent.pdf', filename: 'permanent.pdf', userMessage: 'Add permanent file' }); const addParsed = JSON.parse(addResult); t.is(addParsed.success, true); const fileId = addParsed.fileId; // Mark as permanent const collection = await loadFileCollection(contextId, { useCache: false }); const file = collection.find(f => f.id === fileId); const { updateFileMetadata } = await import('../../../../lib/fileUtils.js'); await updateFileMetadata(contextId, file.hash, { permanent: true }); // Remove from collection const removeResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], fileIds: [fileId], userMessage: 'Remove permanent file' }); const removeParsed = JSON.parse(removeResult); t.is(removeParsed.success, true); t.is(removeParsed.removedCount, 1); // Message should indicate permanent files are not deleted from cloud t.true(removeParsed.message.includes('permanent') || removeParsed.message.includes('Cloud storage cleanup')); // Verify file was removed from collection const listResult = await callPathway('sys_tool_file_collection', { agentContext: [{ contextId, contextKey: null, default: true }], userMessage: 'List files' }); const listParsed = JSON.parse(listResult); t.is(listParsed.totalFiles, 0); } finally { await cleanup(contextId); } }); test('File collection: syncAndStripFilesFromChatHistory only strips collection files', async t => { const contextId = createTestContext(); try { const { syncAndStripFilesFromChatHistory, addFileToCollection } = await import('../../../../lib/fileUtils.js'); // Add one file to collection await addFileToCollection( contextId, null, 'https://example.com/in-collection.jpg', 'gs://bucket/in-collection.jpg', 'in-collection.jpg', [], '', 'hash-in-coll' ); // Create chat history with two files - one in collection, one not const chatHistory = [ { role: 'user', content: [ { type: 'image_url', image_url: { url: 'https://example.com/in-collection.jpg' }, gcs: 'gs://bucket/in-collection.jpg', hash: 'hash-in-coll' }, { type: 'file', url: 'https://example.com/external.pdf', gcs: 'gs://bucket/external.pdf', hash: 'hash-external' } ] } ]; // Process chat history const { chatHistory: processed, availableFiles } = await syncAndStripFilesFromChatHistory(chatHistory, createAgentContext(contextId)); // Verify only collection file was stripped const content = processed[0].content; t.true(Array.isArray(content)); // First file (in collection) should be stripped to placeholder t.is(content[0].type, 'text'); t.true(content[0].text.includes('[File:')); t.true(content[0].text.includes('available via file tools')); // Second file (not in collection) should remain as-is t.is(content[1].type, 'file'); t.is(content[1].url, 'https://example.com/external.pdf'); // Collection should still have only 1 file (no auto-syncing) const collection = await loadFileCollection(contextId, { useCache: false }); t.is(collection.length, 1); // Available files should list the collection file t.true(availableFiles.includes('in-collection.jpg')); } finally { await cleanup(contextId); } }); test('File collection: syncAndStripFilesFromChatHistory finds and syncs files without inCollection (Labeeb uploads)', async t => { const contextId = createTestContext(); const chatId = `test-chat-${Date.now()}`; try { const { syncAndStripFilesFromChatHistory, writeFileDataToRedis, getRedisClient, loadFileCollection } = await import('../../../../lib/fileUtils.js'); // Create 3 files directly in Redis without inCollection set (simulating Labeeb uploads) const redisClient = await getRedisClient(); t.truthy(redisClient, 'Redis client should be available'); const contextMapKey = `FileStoreMap:ctx:${contextId}`; const file1 = { url: 'https://example.com/labeeb-file-1.txt', gcs: 'gs://bucket/labeeb-file-1.txt', hash: 'hash-labeeb-1', filename: 'labeeb-file-1.txt', displayFilename: 'labeeb-file-1.txt', mimeType: 'text/plain', addedDate: new Date().toISOString(), lastAccessed: new Date().toISOString(), permanent: false, tags: [], notes: '' // Note: inCollection is NOT set }; const file2 = { url: 'https://example.com/labeeb-file-2.txt', gcs: 'gs://bucket/labeeb-file-2.txt', hash: 'hash-labeeb-2', filename: 'labeeb-file-2.txt', displayFilename: 'labeeb-file-2.txt', mimeType: 'text/plain', addedDate: new Date().toISOString(), lastAccessed: new Date().toISOString(), permanent: false, tags: [], notes: '' // Note: inCollection is NOT set }; const file3 = { url: 'https://example.com/labeeb-file-3.txt', gcs: 'gs://bucket/labeeb-file-3.txt', hash: 'hash-labeeb-3', filename: 'labeeb-file-3.txt', displayFilename: 'labeeb-file-3.txt', mimeType: 'text/plain', addedDate: new Date().toISOString(), lastAccessed: new Date().toISOString(), permanent: false, tags: [], notes: '' // Note: inCollection is NOT set }; // Write files directly to Redis (without inCollection) await writeFileDataToRedis(redisClient, contextMapKey, file1.hash, file1, null); await writeFileDataToRedis(redisClient, contextMapKey, file2.hash, file2, null); await writeFileDataToRedis(redisClient, contextMapKey, file3.hash, file3, null); // Verify files exist but don't have inCollection set const allFilesBefore = await loadFileCollection(contextId); const file1Before = allFilesBefore.find(f => f.hash === file1.hash); const file2Before = allFilesBefore.find(f => f.hash === file2.hash); const file3Before = allFilesBefore.find(f => f.hash === file3.hash); t.truthy(file1Before, 'File 1 should exist in Redis'); t.truthy(file2Before, 'File 2 should exist in Redis'); t.truthy(file3Before, 'File 3 should exist in Redis'); t.falsy(file1Before.inCollection, 'File 1 should not have inCollection set'); t.falsy(file2Before.inCollection, 'File 2 should not have inCollection set'); t.falsy(file3Before.inCollection, 'File 3 should not have inCollection set'); // Create chat history with all 3 files const chatHistory = [ { role: 'user', content: [ { type: 'file', url: file1.url, gcs: file1.gcs, hash: file1.hash }, { type: 'file', url: file2.url, gcs: file2.gcs, hash: file2.hash }, { type: 'file', url: file3.url, gcs: file3.gcs, hash: file3.hash }, { type: 'text', text: 'Test message' } ] } ]; // Process chat history - should find all 3 files and sync them const { chatHistory: processed } = await syncAndStripFilesFromChatHistory( chatHistory, createAgentContext(contextId), chatId ); // Verify all 3 files were stripped (found and synced) const content = processed[0].content; t.true(Array.isArray(content), 'Content should be an array'); // All 3 files should be stripped to placeholders const filePlaceholders = content.filter(item => item.type === 'text' && item.text && item.text.includes('[File:') && item.text.includes('available via file tools') ); t.is(filePlaceholders.length, 3, 'All 3 files should be stripped to placeholders'); // Text message should remain const textMessages = content.filter(item => item.type === 'text' && !item.text.includes('[File:')); t.is(textMessages.length, 1, 'Text message should remain'); // Wait a bit for async updates to complete await new Promise(resolve => setTimeout(resolve, 1000)); // Verify files now have inCollection set const allFilesAfter = await loadFileCollection(contextId); const file1After = allFilesAfter.find(f => f.hash === file1.hash); const file2After = allFilesAfter.find(f => f.hash === file2.hash); const file3After = allFilesAfter.find(f => f.hash === file3.hash); t.truthy(file1After, 'File 1 should still exist after sync'); t.truthy(file2After, 'File 2 should still exist after sync'); t.truthy(file3After, 'File 3 should still exist after sync'); // All files should now have inCollection set t.truthy(file1After.inCollection, 'File 1 should have inCollection set after sync'); t.truthy(file2After.inCollection, 'File 2 should have inCollection set after sync'); t.truthy(file3After.inCollection, 'File 3 should have inCollection set after sync'); // Verify inCollection includes chatId or is global const hasChatId = (inCollection) => { if (inCollection === true) return true; // Global if (Array.isArray(inCollection)) { return inCollection.includes('*') || inCollection.includes(chatId); } return false; };