@aj-archipelago/cortex 1.4.29 → 1.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  import test from 'ava';
5
5
  import serverFactory from '../../../../index.js';
6
6
  import { callPathway } from '../../../../lib/pathwayTools.js';
7
- import { generateFileMessageContent, resolveFileParameter, loadFileCollection, syncAndStripFilesFromChatHistory, loadMergedFileCollection } from '../../../../lib/fileUtils.js';
7
+ import { generateFileMessageContent, resolveFileParameter, loadFileCollection, syncAndStripFilesFromChatHistory } from '../../../../lib/fileUtils.js';
8
8
 
9
9
  // Helper to create agentContext from contextId/contextKey
10
10
  const createAgentContext = (contextId, contextKey = null) => [{ contextId, contextKey, default: true }];
@@ -65,7 +65,7 @@ test('File collection: Add file to collection', async t => {
65
65
  t.true(parsed.message.includes('test.jpg'));
66
66
 
67
67
  // Verify it was saved to Redis hash map
68
- const collection = await loadFileCollection(contextId, null, false);
68
+ const collection = await loadFileCollection(contextId, { useCache: false });
69
69
  t.is(collection.length, 1);
70
70
  t.is(collection[0].displayFilename, 'test.jpg');
71
71
  t.is(collection[0].url, 'https://example.com/test.jpg');
@@ -489,7 +489,7 @@ test('generateFileMessageContent should find file by ID', async t => {
489
489
  });
490
490
 
491
491
  // Get the file ID from the collection
492
- const collection = await loadFileCollection(contextId, null, false);
492
+ const collection = await loadFileCollection(contextId, { useCache: false });
493
493
  const fileId = collection[0].id;
494
494
 
495
495
  // Normalize by ID
@@ -580,7 +580,7 @@ test('generateFileMessageContent should detect image type', async t => {
580
580
  userMessage: 'Add image'
581
581
  });
582
582
 
583
- const collection = await loadFileCollection(contextId, null, false);
583
+ const collection = await loadFileCollection(contextId, { useCache: false });
584
584
  const fileId = collection[0].id;
585
585
 
586
586
  const result = await generateFileMessageContent(fileId, createAgentContext(contextId));
@@ -880,7 +880,7 @@ test('File collection: Update file metadata', async t => {
880
880
  const fileId = addParsed.fileId;
881
881
 
882
882
  // Get the hash from the collection
883
- const collection = await loadFileCollection(contextId, null, false);
883
+ const collection = await loadFileCollection(contextId, { useCache: false });
884
884
  const file = collection.find(f => f.id === fileId);
885
885
  t.truthy(file);
886
886
  const hash = file.hash;
@@ -897,7 +897,7 @@ test('File collection: Update file metadata', async t => {
897
897
  t.is(success, true);
898
898
 
899
899
  // Verify metadata was updated
900
- const updatedCollection = await loadFileCollection(contextId, null, false);
900
+ const updatedCollection = await loadFileCollection(contextId, { useCache: false });
901
901
  const updatedFile = updatedCollection.find(f => f.id === fileId);
902
902
  t.truthy(updatedFile);
903
903
  t.is(updatedFile.displayFilename, 'renamed.pdf');
@@ -930,7 +930,7 @@ test('updateFileMetadata should allow updating inCollection', async (t) => {
930
930
  const fileId = addParsed.fileId;
931
931
 
932
932
  // Get the hash from the collection
933
- const collection = await loadFileCollection(contextId, null, false);
933
+ const collection = await loadFileCollection(contextId, { useCache: false });
934
934
  const file = collection.find(f => f.id === fileId);
935
935
  t.truthy(file);
936
936
  const hash = file.hash;
@@ -946,13 +946,16 @@ test('updateFileMetadata should allow updating inCollection', async (t) => {
946
946
  t.is(success1, true);
947
947
 
948
948
  // Verify file is now only in chat-123 (not global)
949
- const collection1 = await loadFileCollection(contextId, null, false);
949
+ // With new unified loadFileCollection, loading without chatIds returns ALL files
950
+ // So we need to check that the file's inCollection is ['chat-123'] not ['*']
951
+ const collection1 = await loadFileCollection(contextId, { useCache: false });
950
952
  const file1 = collection1.find(f => f.id === fileId);
951
- // Should not appear in global collection
952
- t.falsy(file1);
953
+ // File should still appear (all files are returned), but inCollection should be ['chat-123']
954
+ t.truthy(file1);
955
+ t.deepEqual(file1.inCollection, ['chat-123'], 'File should be in chat-123 collection, not global');
953
956
 
954
957
  // Should appear when filtering by chat-123
955
- const collection2 = await loadFileCollection(contextId, null, false, 'chat-123');
958
+ const collection2 = await loadFileCollection(contextId, { chatIds: ['chat-123'], useCache: false });
956
959
  const file2 = collection2.find(f => f.id === fileId);
957
960
  t.truthy(file2);
958
961
 
@@ -963,25 +966,34 @@ test('updateFileMetadata should allow updating inCollection', async (t) => {
963
966
  t.is(success2, true);
964
967
 
965
968
  // Verify file is back in global collection
966
- const collection3 = await loadFileCollection(contextId, null, false);
969
+ const collection3 = await loadFileCollection(contextId, { useCache: false });
967
970
  const file3 = collection3.find(f => f.id === fileId);
968
971
  t.truthy(file3);
972
+ // Verify it's global
973
+ t.true(
974
+ file3.inCollection === true ||
975
+ (Array.isArray(file3.inCollection) && file3.inCollection.includes('*')),
976
+ 'File should be global'
977
+ );
969
978
 
970
979
  // Update inCollection to false (remove from collection)
980
+ // false is normalized to undefined, which means "not in collection"
971
981
  const success3 = await updateFileMetadata(contextId, hash, {
972
982
  inCollection: false
973
983
  });
974
984
  t.is(success3, true);
975
985
 
976
- // Verify file is no longer in collection
977
- const collection4 = await loadFileCollection(contextId, null, false);
986
+ // Verify file still exists but inCollection is undefined (not in collection)
987
+ // When loading without chatIds, undefined files are included (Labeeb uploads, etc.)
988
+ const collection4 = await loadFileCollection(contextId, { useCache: false });
978
989
  const file4 = collection4.find(f => f.id === fileId);
979
- t.falsy(file4);
990
+ t.truthy(file4, 'File should still exist (false → undefined, treated same as Labeeb uploads)');
991
+ t.falsy(file4.inCollection, 'File should have undefined inCollection (not in collection)');
980
992
 
981
- // Also not in chat-specific collection
982
- const collection5 = await loadFileCollection(contextId, null, false, 'chat-123');
993
+ // Not in chat-specific collection (undefined files don't match any chatId)
994
+ const collection5 = await loadFileCollection(contextId, { chatIds: ['chat-123'], useCache: false });
983
995
  const file5 = collection5.find(f => f.id === fileId);
984
- t.falsy(file5);
996
+ t.falsy(file5, 'File should not appear when filtering by chatId (undefined = not in collection)');
985
997
  } finally {
986
998
  await cleanup(contextId);
987
999
  }
@@ -1004,7 +1016,7 @@ test('File collection: Permanent files not deleted on remove', async t => {
1004
1016
  const fileId = addParsed.fileId;
1005
1017
 
1006
1018
  // Mark as permanent
1007
- const collection = await loadFileCollection(contextId, null, false);
1019
+ const collection = await loadFileCollection(contextId, { useCache: false });
1008
1020
  const file = collection.find(f => f.id === fileId);
1009
1021
  const { updateFileMetadata } = await import('../../../../lib/fileUtils.js');
1010
1022
  await updateFileMetadata(contextId, file.hash, { permanent: true });
@@ -1074,7 +1086,7 @@ test('File collection: syncAndStripFilesFromChatHistory only strips collection f
1074
1086
  ];
1075
1087
 
1076
1088
  // Process chat history
1077
- const { chatHistory: processed, availableFiles } = await syncAndStripFilesFromChatHistory(chatHistory, createAgentContext(contextId));
1089
+ const { chatHistory: processed } = await syncAndStripFilesFromChatHistory(chatHistory, createAgentContext(contextId));
1078
1090
 
1079
1091
  // Verify only collection file was stripped
1080
1092
  const content = processed[0].content;
@@ -1090,7 +1102,7 @@ test('File collection: syncAndStripFilesFromChatHistory only strips collection f
1090
1102
  t.is(content[1].url, 'https://example.com/external.pdf');
1091
1103
 
1092
1104
  // Collection should still have only 1 file (no auto-syncing)
1093
- const collection = await loadFileCollection(contextId, null, false);
1105
+ const collection = await loadFileCollection(contextId, { useCache: false });
1094
1106
  t.is(collection.length, 1);
1095
1107
 
1096
1108
  // Available files should list the collection file
@@ -1100,6 +1112,176 @@ test('File collection: syncAndStripFilesFromChatHistory only strips collection f
1100
1112
  }
1101
1113
  });
1102
1114
 
1115
+ test('File collection: syncAndStripFilesFromChatHistory finds and syncs files without inCollection (Labeeb uploads)', async t => {
1116
+ const contextId = createTestContext();
1117
+ const chatId = `test-chat-${Date.now()}`;
1118
+
1119
+ try {
1120
+ const { syncAndStripFilesFromChatHistory, writeFileDataToRedis, getRedisClient, loadFileCollection } = await import('../../../../lib/fileUtils.js');
1121
+
1122
+ // Create 3 files directly in Redis without inCollection set (simulating Labeeb uploads)
1123
+ const redisClient = await getRedisClient();
1124
+ t.truthy(redisClient, 'Redis client should be available');
1125
+
1126
+ const contextMapKey = `FileStoreMap:ctx:${contextId}`;
1127
+
1128
+ const file1 = {
1129
+ url: 'https://example.com/labeeb-file-1.txt',
1130
+ gcs: 'gs://bucket/labeeb-file-1.txt',
1131
+ hash: 'hash-labeeb-1',
1132
+ filename: 'labeeb-file-1.txt',
1133
+ displayFilename: 'labeeb-file-1.txt',
1134
+ mimeType: 'text/plain',
1135
+ addedDate: new Date().toISOString(),
1136
+ lastAccessed: new Date().toISOString(),
1137
+ permanent: false,
1138
+ tags: [],
1139
+ notes: ''
1140
+ // Note: inCollection is NOT set
1141
+ };
1142
+
1143
+ const file2 = {
1144
+ url: 'https://example.com/labeeb-file-2.txt',
1145
+ gcs: 'gs://bucket/labeeb-file-2.txt',
1146
+ hash: 'hash-labeeb-2',
1147
+ filename: 'labeeb-file-2.txt',
1148
+ displayFilename: 'labeeb-file-2.txt',
1149
+ mimeType: 'text/plain',
1150
+ addedDate: new Date().toISOString(),
1151
+ lastAccessed: new Date().toISOString(),
1152
+ permanent: false,
1153
+ tags: [],
1154
+ notes: ''
1155
+ // Note: inCollection is NOT set
1156
+ };
1157
+
1158
+ const file3 = {
1159
+ url: 'https://example.com/labeeb-file-3.txt',
1160
+ gcs: 'gs://bucket/labeeb-file-3.txt',
1161
+ hash: 'hash-labeeb-3',
1162
+ filename: 'labeeb-file-3.txt',
1163
+ displayFilename: 'labeeb-file-3.txt',
1164
+ mimeType: 'text/plain',
1165
+ addedDate: new Date().toISOString(),
1166
+ lastAccessed: new Date().toISOString(),
1167
+ permanent: false,
1168
+ tags: [],
1169
+ notes: ''
1170
+ // Note: inCollection is NOT set
1171
+ };
1172
+
1173
+ // Write files directly to Redis (without inCollection)
1174
+ await writeFileDataToRedis(redisClient, contextMapKey, file1.hash, file1, null);
1175
+ await writeFileDataToRedis(redisClient, contextMapKey, file2.hash, file2, null);
1176
+ await writeFileDataToRedis(redisClient, contextMapKey, file3.hash, file3, null);
1177
+
1178
+ // Verify files exist but don't have inCollection set
1179
+ const allFilesBefore = await loadFileCollection(contextId);
1180
+ const file1Before = allFilesBefore.find(f => f.hash === file1.hash);
1181
+ const file2Before = allFilesBefore.find(f => f.hash === file2.hash);
1182
+ const file3Before = allFilesBefore.find(f => f.hash === file3.hash);
1183
+
1184
+ t.truthy(file1Before, 'File 1 should exist in Redis');
1185
+ t.truthy(file2Before, 'File 2 should exist in Redis');
1186
+ t.truthy(file3Before, 'File 3 should exist in Redis');
1187
+
1188
+ t.falsy(file1Before.inCollection, 'File 1 should not have inCollection set');
1189
+ t.falsy(file2Before.inCollection, 'File 2 should not have inCollection set');
1190
+ t.falsy(file3Before.inCollection, 'File 3 should not have inCollection set');
1191
+
1192
+ // Create chat history with all 3 files
1193
+ const chatHistory = [
1194
+ {
1195
+ role: 'user',
1196
+ content: [
1197
+ {
1198
+ type: 'file',
1199
+ url: file1.url,
1200
+ gcs: file1.gcs,
1201
+ hash: file1.hash
1202
+ },
1203
+ {
1204
+ type: 'file',
1205
+ url: file2.url,
1206
+ gcs: file2.gcs,
1207
+ hash: file2.hash
1208
+ },
1209
+ {
1210
+ type: 'file',
1211
+ url: file3.url,
1212
+ gcs: file3.gcs,
1213
+ hash: file3.hash
1214
+ },
1215
+ {
1216
+ type: 'text',
1217
+ text: 'Test message'
1218
+ }
1219
+ ]
1220
+ }
1221
+ ];
1222
+
1223
+ // Process chat history - should find all 3 files and sync them
1224
+ const { chatHistory: processed } = await syncAndStripFilesFromChatHistory(
1225
+ chatHistory,
1226
+ createAgentContext(contextId),
1227
+ chatId
1228
+ );
1229
+
1230
+ // Verify all 3 files were stripped (found and synced)
1231
+ const content = processed[0].content;
1232
+ t.true(Array.isArray(content), 'Content should be an array');
1233
+
1234
+ // All 3 files should be stripped to placeholders
1235
+ const filePlaceholders = content.filter(item =>
1236
+ item.type === 'text' && item.text && item.text.includes('[File:') && item.text.includes('available via file tools')
1237
+ );
1238
+ t.is(filePlaceholders.length, 3, 'All 3 files should be stripped to placeholders');
1239
+
1240
+ // Text message should remain
1241
+ const textMessages = content.filter(item => item.type === 'text' && !item.text.includes('[File:'));
1242
+ t.is(textMessages.length, 1, 'Text message should remain');
1243
+
1244
+ // Wait a bit for async updates to complete
1245
+ await new Promise(resolve => setTimeout(resolve, 1000));
1246
+
1247
+ // Verify files now have inCollection set
1248
+ const allFilesAfter = await loadFileCollection(contextId);
1249
+ const file1After = allFilesAfter.find(f => f.hash === file1.hash);
1250
+ const file2After = allFilesAfter.find(f => f.hash === file2.hash);
1251
+ const file3After = allFilesAfter.find(f => f.hash === file3.hash);
1252
+
1253
+ t.truthy(file1After, 'File 1 should still exist after sync');
1254
+ t.truthy(file2After, 'File 2 should still exist after sync');
1255
+ t.truthy(file3After, 'File 3 should still exist after sync');
1256
+
1257
+ // All files should now have inCollection set
1258
+ t.truthy(file1After.inCollection, 'File 1 should have inCollection set after sync');
1259
+ t.truthy(file2After.inCollection, 'File 2 should have inCollection set after sync');
1260
+ t.truthy(file3After.inCollection, 'File 3 should have inCollection set after sync');
1261
+
1262
+ // Verify inCollection includes chatId or is global
1263
+ const hasChatId = (inCollection) => {
1264
+ if (inCollection === true) return true; // Global
1265
+ if (Array.isArray(inCollection)) {
1266
+ return inCollection.includes('*') || inCollection.includes(chatId);
1267
+ }
1268
+ return false;
1269
+ };
1270
+
1271
+ t.true(hasChatId(file1After.inCollection), 'File 1 inCollection should include chatId or be global');
1272
+ t.true(hasChatId(file2After.inCollection), 'File 2 inCollection should include chatId or be global');
1273
+ t.true(hasChatId(file3After.inCollection), 'File 3 inCollection should include chatId or be global');
1274
+
1275
+ // Verify lastAccessed was updated
1276
+ t.truthy(file1After.lastAccessed, 'File 1 should have lastAccessed updated');
1277
+ t.truthy(file2After.lastAccessed, 'File 2 should have lastAccessed updated');
1278
+ t.truthy(file3After.lastAccessed, 'File 3 should have lastAccessed updated');
1279
+
1280
+ } finally {
1281
+ await cleanup(contextId);
1282
+ }
1283
+ });
1284
+
1103
1285
  // ============================================
1104
1286
  // UpdateFileMetadata Tool Tests
1105
1287
  // ============================================
@@ -1135,7 +1317,7 @@ test('File collection: UpdateFileMetadata tool - Rename file', async t => {
1135
1317
  t.true(updateParsed.message.includes('renamed to "new-name.pdf"'));
1136
1318
 
1137
1319
  // Verify rename persisted
1138
- const collection = await loadFileCollection(contextId, null, false);
1320
+ const collection = await loadFileCollection(contextId, { useCache: false });
1139
1321
  const updatedFile = collection.find(f => f.id === originalFileId);
1140
1322
  t.truthy(updatedFile);
1141
1323
  t.is(updatedFile.displayFilename, 'new-name.pdf');
@@ -1174,7 +1356,7 @@ test('File collection: UpdateFileMetadata tool - Replace all tags', async t => {
1174
1356
  t.is(updateParsed.success, true);
1175
1357
 
1176
1358
  // Verify tags were replaced
1177
- const collection = await loadFileCollection(contextId, null, false);
1359
+ const collection = await loadFileCollection(contextId, { useCache: false });
1178
1360
  const file = collection.find(f => f.id === addParsed.fileId);
1179
1361
  t.deepEqual(file.tags, ['new', 'replaced', 'tags']);
1180
1362
  } finally {
@@ -1210,7 +1392,7 @@ test('File collection: UpdateFileMetadata tool - Add tags', async t => {
1210
1392
  t.is(updateParsed.success, true);
1211
1393
 
1212
1394
  // Verify tags were added (should contain both old and new)
1213
- const collection = await loadFileCollection(contextId, null, false);
1395
+ const collection = await loadFileCollection(contextId, { useCache: false });
1214
1396
  const file = collection.find(f => f.id === addParsed.fileId);
1215
1397
  t.is(file.tags.length, 4);
1216
1398
  t.true(file.tags.includes('existing'));
@@ -1250,7 +1432,7 @@ test('File collection: UpdateFileMetadata tool - Remove tags', async t => {
1250
1432
  t.is(updateParsed.success, true);
1251
1433
 
1252
1434
  // Verify tags were removed
1253
- const collection = await loadFileCollection(contextId, null, false);
1435
+ const collection = await loadFileCollection(contextId, { useCache: false });
1254
1436
  const file = collection.find(f => f.id === addParsed.fileId);
1255
1437
  t.is(file.tags.length, 2);
1256
1438
  t.true(file.tags.includes('keep'));
@@ -1291,7 +1473,7 @@ test('File collection: UpdateFileMetadata tool - Add and remove tags together',
1291
1473
  t.is(updateParsed.success, true);
1292
1474
 
1293
1475
  // Verify tags were updated correctly
1294
- const collection = await loadFileCollection(contextId, null, false);
1476
+ const collection = await loadFileCollection(contextId, { useCache: false });
1295
1477
  const file = collection.find(f => f.id === addParsed.fileId);
1296
1478
  t.is(file.tags.length, 4);
1297
1479
  t.true(file.tags.includes('old1'));
@@ -1332,7 +1514,7 @@ test('File collection: UpdateFileMetadata tool - Update notes', async t => {
1332
1514
  t.is(updateParsed.success, true);
1333
1515
 
1334
1516
  // Verify notes were updated
1335
- const collection = await loadFileCollection(contextId, null, false);
1517
+ const collection = await loadFileCollection(contextId, { useCache: false });
1336
1518
  const file = collection.find(f => f.id === addParsed.fileId);
1337
1519
  t.is(file.notes, 'Updated notes with more detail');
1338
1520
  } finally {
@@ -1367,7 +1549,7 @@ test('File collection: UpdateFileMetadata tool - Update permanent flag', async t
1367
1549
  t.is(updateParsed.success, true);
1368
1550
 
1369
1551
  // Verify permanent flag was set
1370
- const collection = await loadFileCollection(contextId, null, false);
1552
+ const collection = await loadFileCollection(contextId, { useCache: false });
1371
1553
  const file = collection.find(f => f.id === addParsed.fileId);
1372
1554
  t.is(file.permanent, true);
1373
1555
  } finally {
@@ -1412,7 +1594,7 @@ test('File collection: UpdateFileMetadata tool - Combined updates', async t => {
1412
1594
  t.true(updateParsed.message.includes('permanent'));
1413
1595
 
1414
1596
  // Verify all updates persisted
1415
- const collection = await loadFileCollection(contextId, null, false);
1597
+ const collection = await loadFileCollection(contextId, { useCache: false });
1416
1598
  const file = collection.find(f => f.id === originalFileId);
1417
1599
  t.is(file.displayFilename, 'renamed-and-tagged.pdf');
1418
1600
  t.deepEqual(file.tags, ['new', 'tags']);
@@ -1472,7 +1654,7 @@ test('File collection: UpdateFileMetadata tool - Find file by ID', async t => {
1472
1654
  t.is(updateParsed.success, true);
1473
1655
 
1474
1656
  // Verify update worked
1475
- const collection = await loadFileCollection(contextId, null, false);
1657
+ const collection = await loadFileCollection(contextId, { useCache: false });
1476
1658
  const file = collection.find(f => f.id === fileId);
1477
1659
  t.is(file.displayFilename, 'renamed-by-id.pdf');
1478
1660
  } finally {
@@ -1513,7 +1695,7 @@ test('File collection: addFileToCollection returns correct ID for existing files
1513
1695
  t.is(addParsed2.fileId, firstFileId, 'Second add should return same ID as first');
1514
1696
 
1515
1697
  // Verify only one entry exists (not duplicated)
1516
- const collection = await loadFileCollection(contextId, null, false);
1698
+ const collection = await loadFileCollection(contextId, { useCache: false });
1517
1699
  t.is(collection.length, 1);
1518
1700
 
1519
1701
  // Verify metadata was merged (tags from second add, but same ID)
@@ -1526,6 +1708,83 @@ test('File collection: addFileToCollection returns correct ID for existing files
1526
1708
  }
1527
1709
  });
1528
1710
 
1711
+ test('File collection: AddFileToCollection adds same file to multiple chats without creating duplicates', async t => {
1712
+ const contextId = createTestContext();
1713
+ const chatId1 = `test-chat-1-${Date.now()}`;
1714
+ const chatId2 = `test-chat-2-${Date.now()}`;
1715
+
1716
+ try {
1717
+ // Add file to chat-1
1718
+ const addResult1 = await callPathway('sys_tool_file_collection', {
1719
+ agentContext: [{ contextId, contextKey: null, default: true }],
1720
+ url: 'https://example.com/shared-file.pdf',
1721
+ filename: 'shared-file.pdf',
1722
+ tags: ['shared'],
1723
+ userMessage: 'Add file to chat-1',
1724
+ chatId: chatId1
1725
+ });
1726
+
1727
+ const addParsed1 = JSON.parse(addResult1);
1728
+ t.is(addParsed1.success, true);
1729
+ const fileId1 = addParsed1.fileId;
1730
+
1731
+ // Add same file (same URL = same hash) to chat-2
1732
+ const addResult2 = await callPathway('sys_tool_file_collection', {
1733
+ agentContext: [{ contextId, contextKey: null, default: true }],
1734
+ url: 'https://example.com/shared-file.pdf',
1735
+ filename: 'shared-file.pdf',
1736
+ tags: ['shared'],
1737
+ userMessage: 'Add same file to chat-2',
1738
+ chatId: chatId2
1739
+ });
1740
+
1741
+ const addParsed2 = JSON.parse(addResult2);
1742
+ t.is(addParsed2.success, true);
1743
+
1744
+ // Should return the same file ID (no duplicate created)
1745
+ t.is(addParsed2.fileId, fileId1, 'Second add should return same ID (no duplicate)');
1746
+
1747
+ // Verify only one file entry exists (match by URL since hash is computed from URL)
1748
+ const allFiles = await loadFileCollection(contextId, { useCache: false });
1749
+ const matchingFiles = allFiles.filter(f => f.url === 'https://example.com/shared-file.pdf');
1750
+ t.is(matchingFiles.length, 1, 'Should have only one file entry (no duplicate)');
1751
+
1752
+ const file = matchingFiles[0];
1753
+ t.is(file.id, fileId1, 'File ID should match');
1754
+
1755
+ // Verify inCollection contains both chatIds (reference counting)
1756
+ t.truthy(file.inCollection, 'File should have inCollection set');
1757
+
1758
+ if (Array.isArray(file.inCollection)) {
1759
+ // Both chatIds should be in the array
1760
+ t.true(
1761
+ file.inCollection.includes(chatId1) && file.inCollection.includes(chatId2),
1762
+ `inCollection should contain both chatIds: ${JSON.stringify(file.inCollection)}`
1763
+ );
1764
+ } else if (file.inCollection === true) {
1765
+ // If it's global (true), that's also valid (one chat might have been global)
1766
+ t.pass('File is global (inCollection=true), which is valid');
1767
+ } else {
1768
+ t.fail(`Unexpected inCollection value: ${JSON.stringify(file.inCollection)}`);
1769
+ }
1770
+
1771
+ // Verify file appears in both chats' collections when filtered
1772
+ const chat1Files = await loadFileCollection(contextId, { chatIds: [chatId1], useCache: false });
1773
+ const fileInChat1 = chat1Files.find(f => f.id === fileId1);
1774
+ t.truthy(fileInChat1, 'File should appear in chat-1 collection');
1775
+
1776
+ const chat2Files = await loadFileCollection(contextId, { chatIds: [chatId2], useCache: false });
1777
+ const fileInChat2 = chat2Files.find(f => f.id === fileId1);
1778
+ t.truthy(fileInChat2, 'File should appear in chat-2 collection');
1779
+
1780
+ // Verify it's the same file object (same ID)
1781
+ t.is(fileInChat1.id, fileInChat2.id, 'Both chats should reference the same file');
1782
+
1783
+ } finally {
1784
+ await cleanup(contextId);
1785
+ }
1786
+ });
1787
+
1529
1788
  // ============================================
1530
1789
  // File Collection Encryption Tests
1531
1790
  // ============================================
@@ -1552,7 +1811,7 @@ test('File collection encryption: Encrypt tags and notes with contextKey', async
1552
1811
  const { getRedisClient } = await import('../../../../lib/fileUtils.js');
1553
1812
  const redisClient = await getRedisClient();
1554
1813
  const contextMapKey = `FileStoreMap:ctx:${contextId}`;
1555
- const collection = await loadFileCollection(contextId, contextKey, false);
1814
+ const collection = await loadFileCollection({ contextId, contextKey, default: true }, { useCache: false });
1556
1815
  const file = collection.find(f => f.id === parsed.fileId);
1557
1816
  t.truthy(file);
1558
1817
 
@@ -1601,7 +1860,7 @@ test('File collection encryption: Empty tags and notes are not encrypted', async
1601
1860
  const { getRedisClient } = await import('../../../../lib/fileUtils.js');
1602
1861
  const redisClient = await getRedisClient();
1603
1862
  const contextMapKey = `FileStoreMap:ctx:${contextId}`;
1604
- const collection = await loadFileCollection(contextId, contextKey, false);
1863
+ const collection = await loadFileCollection({ contextId, contextKey, default: true }, { useCache: false });
1605
1864
  const file = collection.find(f => f.id === parsed.fileId);
1606
1865
  t.truthy(file);
1607
1866
 
@@ -1641,7 +1900,7 @@ test('File collection encryption: Decryption fails with wrong contextKey', async
1641
1900
  t.is(parsed.success, true);
1642
1901
 
1643
1902
  // Try to load with wrong key
1644
- const collection = await loadFileCollection(contextId, wrongKey, false);
1903
+ const collection = await loadFileCollection({ contextId, contextKey: wrongKey, default: true }, { useCache: false });
1645
1904
  const file = collection.find(f => f.id === parsed.fileId);
1646
1905
  t.truthy(file);
1647
1906
 
@@ -1691,7 +1950,7 @@ test('File collection encryption: Migration from unencrypted to encrypted', asyn
1691
1950
  const { getRedisClient } = await import('../../../../lib/fileUtils.js');
1692
1951
  const redisClient = await getRedisClient();
1693
1952
  const contextMapKey = `FileStoreMap:ctx:${contextId}`;
1694
- const collection1 = await loadFileCollection(contextId, null, false);
1953
+ const collection1 = await loadFileCollection(contextId, { useCache: false });
1695
1954
  const file1 = collection1.find(f => f.id === parsed1.fileId);
1696
1955
  t.truthy(file1);
1697
1956
 
@@ -1721,7 +1980,7 @@ test('File collection encryption: Migration from unencrypted to encrypted', asyn
1721
1980
  t.true(rawData2.notes.includes(':'), 'Encrypted notes should contain IV separator');
1722
1981
 
1723
1982
  // Verify decryption works
1724
- const collection2 = await loadFileCollection(contextId, contextKey, false);
1983
+ const collection2 = await loadFileCollection({ contextId, contextKey, default: true }, { useCache: false });
1725
1984
  const file2 = collection2.find(f => f.id === parsed1.fileId);
1726
1985
  t.deepEqual(file2.tags, ['encrypted'], 'Tags should be decrypted correctly');
1727
1986
  t.is(file2.notes, 'Encrypted notes', 'Notes should be decrypted correctly');
@@ -1753,7 +2012,7 @@ test('File collection encryption: Core fields are never encrypted', async t => {
1753
2012
  const { getRedisClient } = await import('../../../../lib/fileUtils.js');
1754
2013
  const redisClient = await getRedisClient();
1755
2014
  const contextMapKey = `FileStoreMap:ctx:${contextId}`;
1756
- const collection = await loadFileCollection(contextId, contextKey, false);
2015
+ const collection = await loadFileCollection({ contextId, contextKey, default: true }, { useCache: false });
1757
2016
  const file = collection.find(f => f.id === parsed.fileId);
1758
2017
  t.truthy(file);
1759
2018
 
@@ -1795,7 +2054,7 @@ test('File collection encryption: Works without contextKey (no encryption)', asy
1795
2054
  const { getRedisClient } = await import('../../../../lib/fileUtils.js');
1796
2055
  const redisClient = await getRedisClient();
1797
2056
  const contextMapKey = `FileStoreMap:ctx:${contextId}`;
1798
- const collection = await loadFileCollection(contextId, null, false);
2057
+ const collection = await loadFileCollection(contextId, { useCache: false });
1799
2058
  const file = collection.find(f => f.id === parsed.fileId);
1800
2059
  t.truthy(file);
1801
2060
 
@@ -1849,7 +2108,7 @@ test('File collection: YouTube URLs are rejected (cannot be added to collection)
1849
2108
  }
1850
2109
 
1851
2110
  // Verify it was NOT added to collection
1852
- const collection = await loadFileCollection(contextId, null, false);
2111
+ const collection = await loadFileCollection(contextId, { useCache: false });
1853
2112
  t.is(collection.length, 0);
1854
2113
  } catch (error) {
1855
2114
  // If callPathway throws, verify the error message
@@ -1859,7 +2118,7 @@ test('File collection: YouTube URLs are rejected (cannot be added to collection)
1859
2118
  );
1860
2119
 
1861
2120
  // Verify it was NOT added to collection
1862
- const collection = await loadFileCollection(contextId, null, false);
2121
+ const collection = await loadFileCollection(contextId, { useCache: false });
1863
2122
  t.is(collection.length, 0);
1864
2123
  } finally {
1865
2124
  await cleanup(contextId);
@@ -1886,11 +2145,11 @@ test('File collection: YouTube Shorts URLs are rejected', async t => {
1886
2145
  t.true(result.includes('YouTube URLs cannot be added'));
1887
2146
  }
1888
2147
 
1889
- const collection = await loadFileCollection(contextId, null, false);
2148
+ const collection = await loadFileCollection(contextId, { useCache: false });
1890
2149
  t.is(collection.length, 0);
1891
2150
  } catch (error) {
1892
2151
  t.true(error.message.includes('YouTube URLs cannot be added'));
1893
- const collection = await loadFileCollection(contextId, null, false);
2152
+ const collection = await loadFileCollection(contextId, { useCache: false });
1894
2153
  t.is(collection.length, 0);
1895
2154
  } finally {
1896
2155
  await cleanup(contextId);
@@ -1917,11 +2176,11 @@ test('File collection: youtu.be URLs are rejected', async t => {
1917
2176
  t.true(result.includes('YouTube URLs cannot be added'));
1918
2177
  }
1919
2178
 
1920
- const collection = await loadFileCollection(contextId, null, false);
2179
+ const collection = await loadFileCollection(contextId, { useCache: false });
1921
2180
  t.is(collection.length, 0);
1922
2181
  } catch (error) {
1923
2182
  t.true(error.message.includes('YouTube URLs cannot be added'));
1924
- const collection = await loadFileCollection(contextId, null, false);
2183
+ const collection = await loadFileCollection(contextId, { useCache: false });
1925
2184
  t.is(collection.length, 0);
1926
2185
  } finally {
1927
2186
  await cleanup(contextId);
@@ -1943,7 +2202,7 @@ test('generateFileMessageContent: Accepts direct YouTube URL without collection'
1943
2202
  t.falsy(fileContent.hash);
1944
2203
 
1945
2204
  // Verify it's not in the collection
1946
- const collection = await loadFileCollection(contextId, null, false);
2205
+ const collection = await loadFileCollection(contextId, { useCache: false });
1947
2206
  t.is(collection.length, 0);
1948
2207
  } finally {
1949
2208
  await cleanup(contextId);
@@ -1970,7 +2229,7 @@ test('Analyzer tool: Returns error JSON format when file not found', async t =>
1970
2229
  try {
1971
2230
  const result = await callPathway('sys_tool_analyzefile', {
1972
2231
  agentContext: [{ contextId, contextKey: null, default: true }],
1973
- file: 'non-existent-file.jpg',
2232
+ files: ['non-existent-file.jpg'],
1974
2233
  detailedInstructions: 'Analyze this file',
1975
2234
  userMessage: 'Testing error handling'
1976
2235
  });
@@ -2009,7 +2268,7 @@ test('Analyzer tool: Works with legacy contextId/contextKey parameters (backward
2009
2268
  });
2010
2269
 
2011
2270
  // Get the file ID from the collection
2012
- const collection = await loadFileCollection(contextId, null, false);
2271
+ const collection = await loadFileCollection(contextId, { useCache: false });
2013
2272
  const fileId = collection[0].id;
2014
2273
 
2015
2274
  // Test analyzer tool with legacy contextId/contextKey (without agentContext)
@@ -2017,7 +2276,7 @@ test('Analyzer tool: Works with legacy contextId/contextKey parameters (backward
2017
2276
  const result = await callPathway('sys_tool_analyzefile', {
2018
2277
  contextId, // Legacy format - no agentContext
2019
2278
  contextKey: null,
2020
- file: fileId,
2279
+ files: [fileId],
2021
2280
  detailedInstructions: 'What is this file?',
2022
2281
  userMessage: 'Testing backward compatibility'
2023
2282
  });
@@ -2055,13 +2314,13 @@ test('Analyzer tool: File resolution works with agentContext', async t => {
2055
2314
  });
2056
2315
 
2057
2316
  // Get the file ID from the collection
2058
- const collection = await loadFileCollection(contextId, null, false);
2317
+ const collection = await loadFileCollection(contextId, { useCache: false });
2059
2318
  const fileId = collection[0].id;
2060
2319
 
2061
2320
  // Test analyzer tool with agentContext (modern format)
2062
2321
  const result = await callPathway('sys_tool_analyzefile', {
2063
2322
  agentContext: [{ contextId, contextKey: null, default: true }],
2064
- file: fileId,
2323
+ files: [fileId],
2065
2324
  detailedInstructions: 'What is this file?',
2066
2325
  userMessage: 'Testing with agentContext'
2067
2326
  });
@@ -2106,7 +2365,7 @@ test('Converted files: displayFilename .docx but URL .md - MIME type from URL',
2106
2365
  t.is(addParsed.success, true);
2107
2366
 
2108
2367
  // Verify file was stored correctly
2109
- const collection = await loadFileCollection(contextId, null, false);
2368
+ const collection = await loadFileCollection(contextId, { useCache: false });
2110
2369
  t.is(collection.length, 1);
2111
2370
 
2112
2371
  const file = collection[0];
@@ -2137,7 +2396,7 @@ test('Converted files: EditFile should use URL MIME type, not displayFilename',
2137
2396
  t.is(addParsed.success, true);
2138
2397
 
2139
2398
  // Verify the file has correct MIME type from URL
2140
- const collection = await loadFileCollection(contextId, null, false);
2399
+ const collection = await loadFileCollection(contextId, { useCache: false });
2141
2400
  const file = collection[0];
2142
2401
  t.is(file.mimeType, 'text/markdown', 'MIME type should be from URL');
2143
2402
  t.is(file.displayFilename, 'report.docx', 'displayFilename should be original');
@@ -2170,7 +2429,7 @@ test('Converted files: ReadFile should accept text files based on URL, not displ
2170
2429
  t.is(addParsed.success, true);
2171
2430
 
2172
2431
  // Verify file setup
2173
- const collection = await loadFileCollection(contextId, null, false);
2432
+ const collection = await loadFileCollection(contextId, { useCache: false });
2174
2433
  const file = collection[0];
2175
2434
  t.is(file.displayFilename, 'document.docx');
2176
2435
  t.is(file.mimeType, 'text/markdown');
@@ -2219,7 +2478,7 @@ test('Converted files: Multiple converted files with different extensions', asyn
2219
2478
  });
2220
2479
 
2221
2480
  // Verify all files have correct MIME types from URLs
2222
- const collection = await loadFileCollection(contextId, null, false);
2481
+ const collection = await loadFileCollection(contextId, { useCache: false });
2223
2482
  t.is(collection.length, 3);
2224
2483
 
2225
2484
  const doc1 = collection.find(f => f.displayFilename === 'document1.docx');
@@ -2277,7 +2536,7 @@ test('Converted files: loadFileCollection should use converted values as primary
2277
2536
  await writeFileDataToRedis(redisClient, contextMapKey, hash, fileData, null);
2278
2537
 
2279
2538
  // Load the collection
2280
- const collection = await loadFileCollection(contextId, null, false);
2539
+ const collection = await loadFileCollection(contextId, { useCache: false });
2281
2540
  t.is(collection.length, 1);
2282
2541
 
2283
2542
  const file = collection[0];
@@ -2324,8 +2583,8 @@ test('Converted files: loadFileCollection should use converted values as primary
2324
2583
  }
2325
2584
  });
2326
2585
 
2327
- test('loadMergedFileCollection should merge collections from contextId and altContextId', async t => {
2328
- const { loadMergedFileCollection, addFileToCollection, getRedisClient } = await import('../../../../lib/fileUtils.js');
2586
+ test('loadFileCollection should merge collections from multiple contexts', async t => {
2587
+ const { loadFileCollection, addFileToCollection, getRedisClient } = await import('../../../../lib/fileUtils.js');
2329
2588
 
2330
2589
  const contextId = `test-primary-${Date.now()}`;
2331
2590
  const altContextId = `test-alt-${Date.now()}`;
@@ -2338,12 +2597,12 @@ test('loadMergedFileCollection should merge collections from contextId and altCo
2338
2597
  await addFileToCollection(altContextId, null, 'https://example.com/alt.jpg', null, 'alt.jpg', [], '', 'hash-alt');
2339
2598
 
2340
2599
  // Load just primary - should have 1 file
2341
- const primaryOnly = await loadMergedFileCollection([{ contextId, contextKey: null, default: true }]);
2600
+ const primaryOnly = await loadFileCollection([{ contextId, contextKey: null, default: true }]);
2342
2601
  t.is(primaryOnly.length, 1);
2343
2602
  t.is(primaryOnly[0].hash, 'hash-primary');
2344
2603
 
2345
- // Load merged - should have 2 files (both contexts unencrypted)
2346
- const merged = await loadMergedFileCollection([
2604
+ // Load from both contexts - should have 2 files (both contexts unencrypted)
2605
+ const merged = await loadFileCollection([
2347
2606
  { contextId, contextKey: null, default: true },
2348
2607
  { contextId: altContextId, contextKey: null, default: false }
2349
2608
  ]);
@@ -2359,8 +2618,8 @@ test('loadMergedFileCollection should merge collections from contextId and altCo
2359
2618
  }
2360
2619
  });
2361
2620
 
2362
- test('loadMergedFileCollection should dedupe files present in both contexts', async t => {
2363
- const { loadMergedFileCollection, addFileToCollection, getRedisClient } = await import('../../../../lib/fileUtils.js');
2621
+ test('loadFileCollection should dedupe files present in both contexts', async t => {
2622
+ const { loadFileCollection, addFileToCollection, getRedisClient } = await import('../../../../lib/fileUtils.js');
2364
2623
 
2365
2624
  const contextId = `test-primary-dupe-${Date.now()}`;
2366
2625
  const altContextId = `test-alt-dupe-${Date.now()}`;
@@ -2373,8 +2632,8 @@ test('loadMergedFileCollection should dedupe files present in both contexts', as
2373
2632
  // Add unique file to alt context
2374
2633
  await addFileToCollection(altContextId, null, 'https://example.com/alt-only.jpg', null, 'alt-only.jpg', [], '', 'hash-alt-only');
2375
2634
 
2376
- // Load merged - should have 2 files (deduped shared file, both contexts unencrypted)
2377
- const merged = await loadMergedFileCollection([
2635
+ // Load from both contexts - should have 2 files (deduped shared file, both contexts unencrypted)
2636
+ const merged = await loadFileCollection([
2378
2637
  { contextId, contextKey: null, default: true },
2379
2638
  { contextId: altContextId, contextKey: null, default: false }
2380
2639
  ]);
@@ -2710,3 +2969,377 @@ test('File collection: SearchFileCollection normalizes separators (space/dash/un
2710
2969
  await cleanup(contextId);
2711
2970
  }
2712
2971
  });
2972
+
2973
+ test('File collection: SearchFileCollection comprehensive test - all search permutations', async t => {
2974
+ const contextId = createTestContext();
2975
+ const chatId1 = `test-chat-1-${Date.now()}`;
2976
+ const chatId2 = `test-chat-2-${Date.now()}`;
2977
+
2978
+ try {
2979
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
2980
+
2981
+ // Setup: Create files in different chats with various metadata
2982
+ // Chat-1 files
2983
+ await addFileToCollection(contextId, null, 'https://example.com/report-2024.pdf', null, 'Annual Report 2024.pdf', ['finance', 'annual'], 'Year-end financial report', 'hash-report-2024', null, null, false, chatId1);
2984
+ await addFileToCollection(contextId, null, 'https://example.com/budget.xlsx', null, 'Budget Q1.xlsx', ['finance', 'budget'], 'First quarter budget', 'hash-budget-q1', null, null, false, chatId1);
2985
+ await addFileToCollection(contextId, null, 'https://example.com/meeting-notes.txt', null, 'Meeting Notes.txt', ['meetings'], 'Team meeting notes', 'hash-meeting-notes', null, null, false, chatId1);
2986
+
2987
+ // Chat-2 files
2988
+ await addFileToCollection(contextId, null, 'https://example.com/presentation.pdf', null, 'Product Presentation.pdf', ['product', 'sales'], 'Product launch presentation', 'hash-presentation', null, null, false, chatId2);
2989
+ await addFileToCollection(contextId, null, 'https://example.com/roadmap.xlsx', null, 'Product Roadmap.xlsx', ['product', 'planning'], 'Product roadmap for 2024', 'hash-roadmap', null, null, false, chatId2);
2990
+
2991
+ // Global files (no chatId)
2992
+ await addFileToCollection(contextId, null, 'https://example.com/company-policy.pdf', null, 'Company Policy.pdf', ['policy', 'hr'], 'Company-wide policies', 'hash-policy', null, null, false, null);
2993
+ await addFileToCollection(contextId, null, 'https://example.com/employee-handbook.pdf', null, 'Employee Handbook.pdf', ['hr', 'handbook'], 'Employee handbook', 'hash-handbook', null, null, false, null);
2994
+
2995
+ // Shared file (same file in both chats)
2996
+ await addFileToCollection(contextId, null, 'https://example.com/shared-doc.pdf', null, 'Shared Document.pdf', ['shared'], 'Document shared across chats', 'hash-shared', null, null, false, chatId1);
2997
+ await addFileToCollection(contextId, null, 'https://example.com/shared-doc.pdf', null, 'Shared Document.pdf', ['shared'], 'Document shared across chats', 'hash-shared', null, null, false, chatId2);
2998
+
2999
+ // Test 1: Search by filename (partial match) within chat-1
3000
+ const result1 = await callPathway('sys_tool_file_collection', {
3001
+ agentContext: [{ contextId, contextKey: null, default: true }],
3002
+ chatId: chatId1,
3003
+ query: 'Report',
3004
+ userMessage: 'Search for Report in chat-1'
3005
+ });
3006
+ const parsed1 = JSON.parse(result1);
3007
+ t.is(parsed1.success, true);
3008
+ t.is(parsed1.count, 1, 'Should find 1 file in chat-1');
3009
+ t.is(parsed1.files[0].displayFilename, 'Annual Report 2024.pdf');
3010
+
3011
+ // Test 2: Search by filename (partial match) across all chats
3012
+ const result2 = await callPathway('sys_tool_file_collection', {
3013
+ agentContext: [{ contextId, contextKey: null, default: true }],
3014
+ chatId: chatId1,
3015
+ query: 'Report',
3016
+ includeAllChats: true,
3017
+ userMessage: 'Search for Report across all chats'
3018
+ });
3019
+ const parsed2 = JSON.parse(result2);
3020
+ t.is(parsed2.success, true);
3021
+ t.is(parsed2.count, 1, 'Should still find 1 file (only one has "Report" in name)');
3022
+
3023
+ // Test 3: Search by tag within chat-1
3024
+ const result3 = await callPathway('sys_tool_file_collection', {
3025
+ agentContext: [{ contextId, contextKey: null, default: true }],
3026
+ chatId: chatId1,
3027
+ query: 'finance',
3028
+ userMessage: 'Search by tag finance in chat-1'
3029
+ });
3030
+ const parsed3 = JSON.parse(result3);
3031
+ t.is(parsed3.success, true);
3032
+ t.is(parsed3.count, 2, 'Should find 2 files with finance tag in chat-1');
3033
+ const filenames3 = parsed3.files.map(f => f.displayFilename);
3034
+ t.true(filenames3.includes('Annual Report 2024.pdf'));
3035
+ t.true(filenames3.includes('Budget Q1.xlsx'));
3036
+
3037
+ // Test 4: Search by tag across all chats
3038
+ const result4 = await callPathway('sys_tool_file_collection', {
3039
+ agentContext: [{ contextId, contextKey: null, default: true }],
3040
+ chatId: chatId1,
3041
+ query: 'product',
3042
+ includeAllChats: true,
3043
+ userMessage: 'Search by tag product across all chats'
3044
+ });
3045
+ const parsed4 = JSON.parse(result4);
3046
+ t.is(parsed4.success, true);
3047
+ t.is(parsed4.count, 2, 'Should find 2 files with product tag from chat-2');
3048
+ const filenames4 = parsed4.files.map(f => f.displayFilename);
3049
+ t.true(filenames4.includes('Product Presentation.pdf'));
3050
+ t.true(filenames4.includes('Product Roadmap.xlsx'));
3051
+
3052
+ // Test 5: Search by notes (partial match)
3053
+ const result5 = await callPathway('sys_tool_file_collection', {
3054
+ agentContext: [{ contextId, contextKey: null, default: true }],
3055
+ chatId: chatId1,
3056
+ query: 'financial',
3057
+ userMessage: 'Search by notes containing financial'
3058
+ });
3059
+ const parsed5 = JSON.parse(result5);
3060
+ t.is(parsed5.success, true);
3061
+ t.is(parsed5.count, 1, 'Should find file with "financial" in notes');
3062
+ t.is(parsed5.files[0].displayFilename, 'Annual Report 2024.pdf');
3063
+
3064
+ // Test 6: Search with tag filter
3065
+ const result6 = await callPathway('sys_tool_file_collection', {
3066
+ agentContext: [{ contextId, contextKey: null, default: true }],
3067
+ chatId: chatId1,
3068
+ query: '2024',
3069
+ tags: ['finance'],
3070
+ userMessage: 'Search for 2024 with finance tag filter'
3071
+ });
3072
+ const parsed6 = JSON.parse(result6);
3073
+ t.is(parsed6.success, true);
3074
+ t.is(parsed6.count, 1, 'Should find 1 file matching both query and tag filter');
3075
+ t.is(parsed6.files[0].displayFilename, 'Annual Report 2024.pdf');
3076
+
3077
+ // Test 7: Search with tag filter (no matches)
3078
+ const result7 = await callPathway('sys_tool_file_collection', {
3079
+ agentContext: [{ contextId, contextKey: null, default: true }],
3080
+ chatId: chatId1,
3081
+ query: 'Report',
3082
+ tags: ['product'],
3083
+ userMessage: 'Search for Report with product tag (should find nothing)'
3084
+ });
3085
+ const parsed7 = JSON.parse(result7);
3086
+ t.is(parsed7.success, true);
3087
+ t.is(parsed7.count, 0, 'Should find no files (Report is in chat-1, product tag is in chat-2)');
3088
+
3089
+ // Test 8: Search with tag filter across all chats
3090
+ const result8 = await callPathway('sys_tool_file_collection', {
3091
+ agentContext: [{ contextId, contextKey: null, default: true }],
3092
+ chatId: chatId1,
3093
+ query: 'Report',
3094
+ tags: ['product'],
3095
+ includeAllChats: true,
3096
+ userMessage: 'Search for Report with product tag across all chats (should find nothing)'
3097
+ });
3098
+ const parsed8 = JSON.parse(result8);
3099
+ t.is(parsed8.success, true);
3100
+ t.is(parsed8.count, 0, 'Should find no files (Report file has finance tag, not product)');
3101
+
3102
+ // Test 9: Search for shared file from chat-1
3103
+ const result9 = await callPathway('sys_tool_file_collection', {
3104
+ agentContext: [{ contextId, contextKey: null, default: true }],
3105
+ chatId: chatId1,
3106
+ query: 'Shared',
3107
+ userMessage: 'Search for Shared file in chat-1'
3108
+ });
3109
+ const parsed9 = JSON.parse(result9);
3110
+ t.is(parsed9.success, true);
3111
+ t.is(parsed9.count, 1, 'Should find shared file in chat-1');
3112
+ t.is(parsed9.files[0].displayFilename, 'Shared Document.pdf');
3113
+
3114
+ // Test 10: Search for shared file from chat-2
3115
+ const result10 = await callPathway('sys_tool_file_collection', {
3116
+ agentContext: [{ contextId, contextKey: null, default: true }],
3117
+ chatId: chatId2,
3118
+ query: 'Shared',
3119
+ userMessage: 'Search for Shared file in chat-2'
3120
+ });
3121
+ const parsed10 = JSON.parse(result10);
3122
+ t.is(parsed10.success, true);
3123
+ t.is(parsed10.count, 1, 'Should find shared file in chat-2');
3124
+ t.is(parsed10.files[0].displayFilename, 'Shared Document.pdf');
3125
+
3126
+ // Test 11: Search for shared file across all chats (should still be 1 result, not 2)
3127
+ const result11 = await callPathway('sys_tool_file_collection', {
3128
+ agentContext: [{ contextId, contextKey: null, default: true }],
3129
+ chatId: chatId1,
3130
+ query: 'Shared',
3131
+ includeAllChats: true,
3132
+ userMessage: 'Search for Shared file across all chats'
3133
+ });
3134
+ const parsed11 = JSON.parse(result11);
3135
+ t.is(parsed11.success, true);
3136
+ t.is(parsed11.count, 1, 'Should find 1 shared file (not duplicated)');
3137
+ t.is(parsed11.files[0].displayFilename, 'Shared Document.pdf');
3138
+
3139
+ // Test 12: Search with limit
3140
+ const result12 = await callPathway('sys_tool_file_collection', {
3141
+ agentContext: [{ contextId, contextKey: null, default: true }],
3142
+ chatId: chatId1,
3143
+ query: 'pdf',
3144
+ limit: 2,
3145
+ userMessage: 'Search for pdf with limit 2'
3146
+ });
3147
+ const parsed12 = JSON.parse(result12);
3148
+ t.is(parsed12.success, true);
3149
+ t.true(parsed12.count <= 2, 'Should respect limit');
3150
+ t.true(parsed12.files.length <= 2, 'Results array should respect limit');
3151
+
3152
+ // Test 13: Search for global files from chat-1
3153
+ const result13 = await callPathway('sys_tool_file_collection', {
3154
+ agentContext: [{ contextId, contextKey: null, default: true }],
3155
+ chatId: chatId1,
3156
+ query: 'Policy',
3157
+ userMessage: 'Search for Policy (global file) from chat-1'
3158
+ });
3159
+ const parsed13 = JSON.parse(result13);
3160
+ t.is(parsed13.success, true);
3161
+ t.is(parsed13.count, 1, 'Should find global file from chat-1');
3162
+ t.is(parsed13.files[0].displayFilename, 'Company Policy.pdf');
3163
+
3164
+ // Test 14: Search with normalized matching (space/dash/underscore)
3165
+ await addFileToCollection(contextId, null, 'https://example.com/test-file.pdf', null, 'Test File Name.pdf', [], '', 'hash-test-normalized', null, null, false, chatId1);
3166
+ const result14 = await callPathway('sys_tool_file_collection', {
3167
+ agentContext: [{ contextId, contextKey: null, default: true }],
3168
+ chatId: chatId1,
3169
+ query: 'Test-File',
3170
+ userMessage: 'Search with dash should match space-separated'
3171
+ });
3172
+ const parsed14 = JSON.parse(result14);
3173
+ t.is(parsed14.success, true);
3174
+ t.is(parsed14.count, 1, 'Should find file with normalized matching');
3175
+ t.is(parsed14.files[0].displayFilename, 'Test File Name.pdf');
3176
+
3177
+ // Test 15: Search with multiple tag filters
3178
+ const result15 = await callPathway('sys_tool_file_collection', {
3179
+ agentContext: [{ contextId, contextKey: null, default: true }],
3180
+ chatId: chatId1,
3181
+ query: 'Report',
3182
+ tags: ['finance', 'annual'],
3183
+ userMessage: 'Search with multiple tag filters'
3184
+ });
3185
+ const parsed15 = JSON.parse(result15);
3186
+ t.is(parsed15.success, true);
3187
+ t.is(parsed15.count, 1, 'Should find file matching all tags');
3188
+ t.is(parsed15.files[0].displayFilename, 'Annual Report 2024.pdf');
3189
+
3190
+ // Test 16: Search with no matches
3191
+ const result16 = await callPathway('sys_tool_file_collection', {
3192
+ agentContext: [{ contextId, contextKey: null, default: true }],
3193
+ chatId: chatId1,
3194
+ query: 'NonexistentFile12345',
3195
+ userMessage: 'Search for nonexistent file'
3196
+ });
3197
+ const parsed16 = JSON.parse(result16);
3198
+ t.is(parsed16.success, true);
3199
+ t.is(parsed16.count, 0, 'Should find no files');
3200
+ t.true(parsed16.message.includes('No files found'), 'Should have helpful message');
3201
+
3202
+ // Test 17: Search case-insensitive
3203
+ const result17 = await callPathway('sys_tool_file_collection', {
3204
+ agentContext: [{ contextId, contextKey: null, default: true }],
3205
+ chatId: chatId1,
3206
+ query: 'ANNUAL REPORT',
3207
+ userMessage: 'Search case-insensitive'
3208
+ });
3209
+ const parsed17 = JSON.parse(result17);
3210
+ t.is(parsed17.success, true);
3211
+ t.is(parsed17.count, 1, 'Should find file with case-insensitive match');
3212
+ t.is(parsed17.files[0].displayFilename, 'Annual Report 2024.pdf');
3213
+
3214
+ // Test 18: Search from chat-2 (different chat)
3215
+ const result18 = await callPathway('sys_tool_file_collection', {
3216
+ agentContext: [{ contextId, contextKey: null, default: true }],
3217
+ chatId: chatId2,
3218
+ query: 'Product',
3219
+ userMessage: 'Search for Product in chat-2'
3220
+ });
3221
+ const parsed18 = JSON.parse(result18);
3222
+ t.is(parsed18.success, true);
3223
+ t.is(parsed18.count, 2, 'Should find 2 product files in chat-2');
3224
+ const filenames18 = parsed18.files.map(f => f.displayFilename);
3225
+ t.true(filenames18.includes('Product Presentation.pdf'));
3226
+ t.true(filenames18.includes('Product Roadmap.xlsx'));
3227
+
3228
+ // Test 19: Search from chat-2 should NOT see chat-1 files (without includeAllChats)
3229
+ const result19 = await callPathway('sys_tool_file_collection', {
3230
+ agentContext: [{ contextId, contextKey: null, default: true }],
3231
+ chatId: chatId2,
3232
+ query: 'Report',
3233
+ userMessage: 'Search for Report in chat-2 (should not find chat-1 file)'
3234
+ });
3235
+ const parsed19 = JSON.parse(result19);
3236
+ t.is(parsed19.success, true);
3237
+ // Should find global files with "Report" but not chat-1 specific files
3238
+ // Actually, there are no global files with "Report", so should be 0
3239
+ t.is(parsed19.count, 0, 'Should not find chat-1 files from chat-2');
3240
+
3241
+ // Test 20: Search for files matching multiple criteria (query + tags + notes)
3242
+ const result20 = await callPathway('sys_tool_file_collection', {
3243
+ agentContext: [{ contextId, contextKey: null, default: true }],
3244
+ chatId: chatId1,
3245
+ query: 'budget',
3246
+ tags: ['finance'],
3247
+ userMessage: 'Search for budget with finance tag'
3248
+ });
3249
+ const parsed20 = JSON.parse(result20);
3250
+ t.is(parsed20.success, true);
3251
+ t.is(parsed20.count, 1, 'Should find file matching query and tag');
3252
+ t.is(parsed20.files[0].displayFilename, 'Budget Q1.xlsx');
3253
+
3254
+ } finally {
3255
+ await cleanup(contextId);
3256
+ }
3257
+ });
3258
+
3259
+ test('File collection: SearchFileCollection with compound context (user + workspace)', async t => {
3260
+ const userContextId = `test-user-search-${Date.now()}`;
3261
+ const workspaceContextId = `test-workspace-search-${Date.now()}`;
3262
+ const chatId = `test-chat-${Date.now()}`;
3263
+
3264
+ try {
3265
+ const { addFileToCollection } = await import('../../../../lib/fileUtils.js');
3266
+
3267
+ // Add files to user context
3268
+ await addFileToCollection(userContextId, null, 'https://example.com/user-doc.pdf', null, 'User Document.pdf', ['personal'], 'Personal document', 'hash-user-doc', null, null, false, chatId);
3269
+ await addFileToCollection(userContextId, null, 'https://example.com/user-notes.txt', null, 'User Notes.txt', ['personal', 'notes'], 'Personal notes', 'hash-user-notes', null, null, false, chatId);
3270
+
3271
+ // Add files to workspace context
3272
+ await addFileToCollection(workspaceContextId, null, 'https://example.com/workspace-doc.pdf', null, 'Workspace Document.pdf', ['workspace'], 'Workspace document', 'hash-workspace-doc', null, null, false, chatId);
3273
+ await addFileToCollection(workspaceContextId, null, 'https://example.com/workspace-data.xlsx', null, 'Workspace Data.xlsx', ['workspace', 'data'], 'Workspace data', 'hash-workspace-data', null, null, false, chatId);
3274
+
3275
+ // Define compound agentContext
3276
+ const agentContext = [
3277
+ { contextId: userContextId, contextKey: null, default: true },
3278
+ { contextId: workspaceContextId, contextKey: null, default: false }
3279
+ ];
3280
+
3281
+ // Test 1: Search across both contexts
3282
+ const result1 = await callPathway('sys_tool_file_collection', {
3283
+ agentContext: agentContext,
3284
+ chatId: chatId,
3285
+ query: 'Document',
3286
+ userMessage: 'Search for Document across user and workspace contexts'
3287
+ });
3288
+ const parsed1 = JSON.parse(result1);
3289
+ t.is(parsed1.success, true);
3290
+ t.is(parsed1.count, 2, 'Should find documents from both contexts');
3291
+ const filenames1 = parsed1.files.map(f => f.displayFilename);
3292
+ t.true(filenames1.includes('User Document.pdf'));
3293
+ t.true(filenames1.includes('Workspace Document.pdf'));
3294
+
3295
+ // Test 2: Search by tag across both contexts
3296
+ const result2 = await callPathway('sys_tool_file_collection', {
3297
+ agentContext: agentContext,
3298
+ chatId: chatId,
3299
+ query: 'personal',
3300
+ userMessage: 'Search by personal tag across contexts'
3301
+ });
3302
+ const parsed2 = JSON.parse(result2);
3303
+ t.is(parsed2.success, true);
3304
+ t.is(parsed2.count, 2, 'Should find 2 files with personal tag from user context');
3305
+ const filenames2 = parsed2.files.map(f => f.displayFilename);
3306
+ t.true(filenames2.includes('User Document.pdf'));
3307
+ t.true(filenames2.includes('User Notes.txt'));
3308
+
3309
+ // Test 3: Search by tag with includeAllChats (should still work with compound context)
3310
+ const result3 = await callPathway('sys_tool_file_collection', {
3311
+ agentContext: agentContext,
3312
+ chatId: chatId,
3313
+ query: 'workspace',
3314
+ includeAllChats: true,
3315
+ userMessage: 'Search by workspace tag across all chats'
3316
+ });
3317
+ const parsed3 = JSON.parse(result3);
3318
+ t.is(parsed3.success, true);
3319
+ t.is(parsed3.count, 2, 'Should find 2 workspace files');
3320
+ const filenames3 = parsed3.files.map(f => f.displayFilename);
3321
+ t.true(filenames3.includes('Workspace Document.pdf'));
3322
+ t.true(filenames3.includes('Workspace Data.xlsx'));
3323
+
3324
+ // Test 4: Search with tag filter across compound context
3325
+ const result4 = await callPathway('sys_tool_file_collection', {
3326
+ agentContext: agentContext,
3327
+ chatId: chatId,
3328
+ query: 'Notes',
3329
+ tags: ['personal'],
3330
+ userMessage: 'Search for Notes with personal tag filter'
3331
+ });
3332
+ const parsed4 = JSON.parse(result4);
3333
+ t.is(parsed4.success, true);
3334
+ t.is(parsed4.count, 1, 'Should find 1 file matching query and tag');
3335
+ t.is(parsed4.files[0].displayFilename, 'User Notes.txt');
3336
+
3337
+ } finally {
3338
+ const { getRedisClient } = await import('../../../../lib/fileUtils.js');
3339
+ const redisClient = await getRedisClient();
3340
+ if (redisClient) {
3341
+ await redisClient.del(`FileStoreMap:ctx:${userContextId}`);
3342
+ await redisClient.del(`FileStoreMap:ctx:${workspaceContextId}`);
3343
+ }
3344
+ }
3345
+ });