@aj-archipelago/cortex 1.4.30 → 1.4.32

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.
@@ -0,0 +1,839 @@
1
+ // file_operations_agent.test.js
2
+ // End-to-end integration tests for file operations with sys_entity_agent
3
+ // Tests scenarios where files are uploaded directly to file handler (like Labeeb does)
4
+ // and then processed by sys_entity_agent
5
+
6
+ import test from 'ava';
7
+ import serverFactory from '../../../../../index.js';
8
+ import { createClient } from 'graphql-ws';
9
+ import ws from 'ws';
10
+ import axios from 'axios';
11
+ import FormData from 'form-data';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { dirname } from 'path';
16
+ import { loadFileCollection, getRedisClient, computeBufferHash, writeFileDataToRedis } from '../../../../../lib/fileUtils.js';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ let testServer;
22
+ let wsClient;
23
+
24
+ // Helper to get file handler URL from config or environment
25
+ function getFileHandlerUrl() {
26
+ // Try environment variable first
27
+ if (process.env.WHISPER_MEDIA_API_URL && process.env.WHISPER_MEDIA_API_URL !== 'null') {
28
+ return process.env.WHISPER_MEDIA_API_URL;
29
+ }
30
+ // Try config from server
31
+ const config = testServer?.config;
32
+ if (config) {
33
+ const url = config.get('whisperMediaApiUrl');
34
+ if (url && url !== 'null') {
35
+ return url;
36
+ }
37
+ }
38
+ // Default to localhost:7071 (usual file handler port)
39
+ return 'http://localhost:7071';
40
+ }
41
+
42
+ // Helper to upload file directly to file handler (like Labeeb does)
43
+ async function uploadFileToHandler(content, filename, contextId) {
44
+ const fileHandlerUrl = getFileHandlerUrl();
45
+ if (!fileHandlerUrl || fileHandlerUrl === 'null') {
46
+ throw new Error('File handler URL not configured');
47
+ }
48
+
49
+ // Create temporary file
50
+ const tempDir = path.join(__dirname, '../../../../../../temp');
51
+ if (!fs.existsSync(tempDir)) {
52
+ fs.mkdirSync(tempDir, { recursive: true });
53
+ }
54
+ const tempFilePath = path.join(tempDir, `test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${filename}`);
55
+ fs.writeFileSync(tempFilePath, content);
56
+
57
+ try {
58
+ // Compute hash from content (client-side, like Labeeb does)
59
+ const contentBuffer = Buffer.from(content);
60
+ const hash = await computeBufferHash(contentBuffer);
61
+
62
+ const form = new FormData();
63
+ form.append('file', fs.createReadStream(tempFilePath), {
64
+ filename: filename,
65
+ contentType: 'application/octet-stream'
66
+ });
67
+ form.append('hash', hash); // Include hash in upload
68
+ if (contextId) {
69
+ form.append('contextId', contextId);
70
+ }
71
+
72
+ // The base URL might already include the path, or might just be the base
73
+ // Try to construct the URL correctly
74
+ let uploadUrl = fileHandlerUrl;
75
+ if (!fileHandlerUrl.includes('/api/') && !fileHandlerUrl.includes('/file-handler')) {
76
+ // Base URL doesn't include path, add the endpoint
77
+ uploadUrl = `${fileHandlerUrl}/api/CortexFileHandler`;
78
+ }
79
+ const response = await axios.post(uploadUrl, form, {
80
+ headers: {
81
+ ...form.getHeaders()
82
+ },
83
+ timeout: 30000,
84
+ validateStatus: (status) => status >= 200 && status < 500
85
+ });
86
+
87
+ if (response.status !== 200 || !response.data?.url) {
88
+ throw new Error(`Upload failed: ${response.status} - ${JSON.stringify(response.data)}`);
89
+ }
90
+
91
+ // Hash should be in response since we provided it
92
+ // Wait a bit for Redis to be updated
93
+ await new Promise(resolve => setTimeout(resolve, 500));
94
+
95
+ return {
96
+ url: response.data.converted?.url || response.data.url,
97
+ gcs: response.data.converted?.gcs || response.data.gcs || null,
98
+ hash: response.data.hash
99
+ };
100
+ } finally {
101
+ // Clean up temp file
102
+ try {
103
+ if (fs.existsSync(tempFilePath)) {
104
+ fs.unlinkSync(tempFilePath);
105
+ }
106
+ } catch (e) {
107
+ // Ignore cleanup errors
108
+ }
109
+ }
110
+ }
111
+
112
+ // Helper to verify file exists in Redis but doesn't have inCollection set
113
+ async function verifyFileInRedisWithoutInCollection(contextId, hash) {
114
+ // Load all files (including those without inCollection)
115
+ const allFiles = await loadFileCollection(contextId);
116
+ const file = allFiles.find(f => f.hash === hash);
117
+ if (!file) return false;
118
+ // File exists but inCollection should be undefined/null
119
+ return file.inCollection === undefined || file.inCollection === null;
120
+ }
121
+
122
+ // Helper to collect subscription events
123
+ async function collectSubscriptionEvents(subscription, timeout = 60000) {
124
+ const events = [];
125
+
126
+ return new Promise((resolve, reject) => {
127
+ const timeoutId = setTimeout(() => {
128
+ if (events.length > 0) {
129
+ resolve(events);
130
+ } else {
131
+ reject(new Error('Subscription timed out with no events'));
132
+ }
133
+ }, timeout);
134
+
135
+ const unsubscribe = wsClient.subscribe(
136
+ {
137
+ query: subscription.query,
138
+ variables: subscription.variables
139
+ },
140
+ {
141
+ next: (event) => {
142
+ events.push(event);
143
+ if (event?.data?.requestProgress?.progress === 1) {
144
+ clearTimeout(timeoutId);
145
+ unsubscribe();
146
+ resolve(events);
147
+ }
148
+ },
149
+ error: (error) => {
150
+ clearTimeout(timeoutId);
151
+ reject(error);
152
+ },
153
+ complete: () => {
154
+ clearTimeout(timeoutId);
155
+ resolve(events);
156
+ }
157
+ }
158
+ );
159
+ });
160
+ }
161
+
162
+ // Helper to clean up test files
163
+ async function cleanup(contextId) {
164
+ try {
165
+ const redisClient = await getRedisClient();
166
+ if (redisClient && contextId) {
167
+ const contextMapKey = `FileStoreMap:ctx:${contextId}`;
168
+ await redisClient.del(contextMapKey);
169
+ }
170
+ } catch (e) {
171
+ // Ignore cleanup errors
172
+ }
173
+ }
174
+
175
+ test.before(async () => {
176
+ process.env.CORTEX_ENABLE_REST = 'true';
177
+ const { server, startServer } = await serverFactory();
178
+ startServer && await startServer();
179
+ testServer = server;
180
+
181
+ // Create WebSocket client for subscriptions
182
+ wsClient = createClient({
183
+ url: `ws://localhost:${process.env.CORTEX_PORT || 4000}/graphql`,
184
+ webSocketImpl: ws,
185
+ retryAttempts: 3,
186
+ connectionParams: {},
187
+ on: {
188
+ error: (error) => {
189
+ console.error('WS connection error:', error);
190
+ }
191
+ }
192
+ });
193
+
194
+ // Test the connection
195
+ try {
196
+ await new Promise((resolve, reject) => {
197
+ const subscription = wsClient.subscribe(
198
+ {
199
+ query: `
200
+ subscription TestConnection {
201
+ requestProgress(requestIds: ["test"]) {
202
+ requestId
203
+ }
204
+ }
205
+ `
206
+ },
207
+ {
208
+ next: () => {
209
+ resolve();
210
+ },
211
+ error: reject,
212
+ complete: () => {
213
+ resolve();
214
+ }
215
+ }
216
+ );
217
+
218
+ setTimeout(() => {
219
+ resolve();
220
+ }, 2000);
221
+ });
222
+ } catch (error) {
223
+ console.error('Failed to establish WebSocket connection:', error);
224
+ throw error;
225
+ }
226
+ });
227
+
228
+ test.after.always('cleanup', async () => {
229
+ if (wsClient) {
230
+ wsClient.dispose();
231
+ }
232
+ if (testServer) {
233
+ await testServer.stop();
234
+ }
235
+ });
236
+
237
+ test('sys_entity_agent processes multiple files uploaded directly to file handler (no inCollection)', async (t) => {
238
+ t.timeout(120000); // 2 minute timeout
239
+
240
+ const contextId = `test-file-ops-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
241
+ const chatId = `test-chat-${Date.now()}`;
242
+
243
+ try {
244
+ // Upload 3 files directly to file handler (like Labeeb does)
245
+ // These will have contextId but no inCollection set
246
+ const file1 = await uploadFileToHandler(
247
+ 'File 1 Content\nThis is the first test file with some content.',
248
+ 'test-file-1.txt',
249
+ contextId
250
+ );
251
+
252
+ const file2 = await uploadFileToHandler(
253
+ 'File 2 Content\nThis is the second test file with different content.',
254
+ 'test-file-2.txt',
255
+ contextId
256
+ );
257
+
258
+ const file3 = await uploadFileToHandler(
259
+ 'File 3 Content\nThis is the third test file with more content.',
260
+ 'test-file-3.txt',
261
+ contextId
262
+ );
263
+
264
+ t.truthy(file1.hash, 'File 1 should have a hash');
265
+ t.truthy(file2.hash, 'File 2 should have a hash');
266
+ t.truthy(file3.hash, 'File 3 should have a hash');
267
+
268
+ // Verify files exist in Redis but don't have inCollection set
269
+ t.true(await verifyFileInRedisWithoutInCollection(contextId, file1.hash), 'File 1 should exist in Redis without inCollection');
270
+ t.true(await verifyFileInRedisWithoutInCollection(contextId, file2.hash), 'File 2 should exist in Redis without inCollection');
271
+ t.true(await verifyFileInRedisWithoutInCollection(contextId, file3.hash), 'File 3 should exist in Redis without inCollection');
272
+
273
+ // Wait a bit for Redis to be fully updated
274
+ await new Promise(resolve => setTimeout(resolve, 1000));
275
+
276
+ // Create chatHistory with all 3 files
277
+ // MultiMessage content must be array of JSON strings
278
+ const chatHistory = [{
279
+ role: 'user',
280
+ content: [
281
+ JSON.stringify({
282
+ type: 'file',
283
+ url: file1.url,
284
+ gcs: file1.gcs,
285
+ hash: file1.hash,
286
+ filename: 'test-file-1.txt'
287
+ }),
288
+ JSON.stringify({
289
+ type: 'file',
290
+ url: file2.url,
291
+ gcs: file2.gcs,
292
+ hash: file2.hash,
293
+ filename: 'test-file-2.txt'
294
+ }),
295
+ JSON.stringify({
296
+ type: 'file',
297
+ url: file3.url,
298
+ gcs: file3.gcs,
299
+ hash: file3.hash,
300
+ filename: 'test-file-3.txt'
301
+ }),
302
+ JSON.stringify({
303
+ type: 'text',
304
+ text: 'Please read all three files and tell me the content of each file. List them as File 1, File 2, and File 3.'
305
+ })
306
+ ]
307
+ }];
308
+
309
+ // Call sys_entity_agent
310
+ const response = await testServer.executeOperation({
311
+ query: `
312
+ query TestFileOperations(
313
+ $text: String!,
314
+ $chatHistory: [MultiMessage]!,
315
+ $contextId: String!,
316
+ $chatId: String
317
+ ) {
318
+ sys_entity_agent(
319
+ text: $text,
320
+ chatHistory: $chatHistory,
321
+ contextId: $contextId,
322
+ chatId: $chatId,
323
+ stream: true
324
+ ) {
325
+ result
326
+ contextId
327
+ tool
328
+ warnings
329
+ errors
330
+ }
331
+ }
332
+ `,
333
+ variables: {
334
+ text: 'Please read all three files and tell me the content of each file. List them as File 1, File 2, and File 3.',
335
+ chatHistory: chatHistory,
336
+ contextId: contextId,
337
+ chatId: chatId
338
+ }
339
+ });
340
+
341
+ t.falsy(response.body?.singleResult?.errors, 'Should not have GraphQL errors');
342
+ const requestId = response.body?.singleResult?.data?.sys_entity_agent?.result;
343
+ t.truthy(requestId, 'Should have a requestId in the result field');
344
+
345
+ // Collect events
346
+ const events = await collectSubscriptionEvents({
347
+ query: `
348
+ subscription OnRequestProgress($requestId: String!) {
349
+ requestProgress(requestIds: [$requestId]) {
350
+ requestId
351
+ progress
352
+ data
353
+ info
354
+ }
355
+ }
356
+ `,
357
+ variables: { requestId }
358
+ }, 120000);
359
+
360
+ t.true(events.length > 0, 'Should have received events');
361
+
362
+ // Verify we got a completion event
363
+ const completionEvent = events.find(event =>
364
+ event.data.requestProgress.progress === 1
365
+ );
366
+ t.truthy(completionEvent, 'Should have received a completion event');
367
+
368
+ // Check the response data for file content
369
+ const responseData = completionEvent.data.requestProgress.data;
370
+ t.truthy(responseData, 'Should have response data');
371
+
372
+ // Parse the data to check for file content
373
+ let parsedData;
374
+ try {
375
+ parsedData = typeof responseData === 'string' ? JSON.parse(responseData) : responseData;
376
+ } catch (e) {
377
+ // If not JSON, treat as string
378
+ parsedData = responseData;
379
+ }
380
+
381
+ const responseText = typeof parsedData === 'string' ? parsedData : JSON.stringify(parsedData);
382
+
383
+ // Verify all three files were processed
384
+ // Check that the agent actually read the files by looking for content from the files
385
+ // File 1 content: "Content of test file 1"
386
+ // File 2 content: "Content of test file 2"
387
+ // File 3 content: "Content of test file 3"
388
+ // The agent should mention at least some content from the files
389
+ const hasFile1Content = responseText.includes('test file 1') || responseText.includes('Content of test file 1') ||
390
+ responseText.includes('File 1') || responseText.includes('file 1') || responseText.includes('first');
391
+ const hasFile2Content = responseText.includes('test file 2') || responseText.includes('Content of test file 2') ||
392
+ responseText.includes('File 2') || responseText.includes('file 2') || responseText.includes('second');
393
+ const hasFile3Content = responseText.includes('test file 3') || responseText.includes('Content of test file 3') ||
394
+ responseText.includes('File 3') || responseText.includes('file 3') || responseText.includes('third');
395
+
396
+ // At minimum, verify the response is non-empty and the agent processed the request
397
+ t.truthy(responseText && responseText.length > 0, 'Agent should return a response');
398
+
399
+ // Log the response for debugging if assertions fail
400
+ if (!hasFile1Content || !hasFile2Content || !hasFile3Content) {
401
+ console.log('Agent response:', responseText.substring(0, 500));
402
+ }
403
+
404
+ // Note: We primarily verify file processing via inCollection checks below
405
+ // The response text check is secondary - the key is that files were synced
406
+
407
+ // Verify files now have inCollection set (they should be synced)
408
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for async updates
409
+
410
+ const allFiles = await loadFileCollection(contextId);
411
+ const file1InCollection = allFiles.find(f => f.hash === file1.hash);
412
+ const file2InCollection = allFiles.find(f => f.hash === file2.hash);
413
+ const file3InCollection = allFiles.find(f => f.hash === file3.hash);
414
+
415
+ t.truthy(file1InCollection, 'File 1 should be in collection after sync');
416
+ t.truthy(file2InCollection, 'File 2 should be in collection after sync');
417
+ t.truthy(file3InCollection, 'File 3 should be in collection after sync');
418
+
419
+ // Verify inCollection is set (should have chatId or be global)
420
+ t.truthy(file1InCollection.inCollection, 'File 1 should have inCollection set');
421
+ t.truthy(file2InCollection.inCollection, 'File 2 should have inCollection set');
422
+ t.truthy(file3InCollection.inCollection, 'File 3 should have inCollection set');
423
+
424
+ // Verify inCollection includes the chatId or is global
425
+ const hasChatId = (inCollection) => {
426
+ if (inCollection === true) return true; // Global
427
+ if (Array.isArray(inCollection)) {
428
+ return inCollection.includes('*') || inCollection.includes(chatId);
429
+ }
430
+ return false;
431
+ };
432
+
433
+ t.true(hasChatId(file1InCollection.inCollection), 'File 1 inCollection should include chatId or be global');
434
+ t.true(hasChatId(file2InCollection.inCollection), 'File 2 inCollection should include chatId or be global');
435
+ t.true(hasChatId(file3InCollection.inCollection), 'File 3 inCollection should include chatId or be global');
436
+
437
+ } catch (error) {
438
+ // If file handler is not configured, skip the test
439
+ if (error.message?.includes('File handler URL not configured') ||
440
+ error.message?.includes('WHISPER_MEDIA_API_URL')) {
441
+ t.log('Test skipped - file handler URL not configured');
442
+ t.pass();
443
+ return;
444
+ }
445
+ throw error;
446
+ } finally {
447
+ await cleanup(contextId);
448
+ }
449
+ });
450
+
451
+ test('sys_entity_agent processes files from compound context (user + workspace)', async t => {
452
+ // Compound context: user context (encrypted) + workspace context (unencrypted)
453
+ // This simulates a workspace being run by a user, where:
454
+ // - User context has encrypted files (user's personal files)
455
+ // - Workspace context has unencrypted files (shared workspace files)
456
+ // - Both should be accessible when agentContext includes both
457
+
458
+ const userContextId = `test-user-${Date.now()}`;
459
+ const workspaceContextId = `test-workspace-${Date.now()}`;
460
+ const userContextKey = 'test-user-encryption-key-12345'; // Simulated encryption key
461
+ const chatId = `test-chat-${Date.now()}`;
462
+
463
+ try {
464
+ const redisClient = await getRedisClient();
465
+ if (!redisClient) {
466
+ t.skip('Redis not available');
467
+ return;
468
+ }
469
+
470
+ // Create files in user context (encrypted)
471
+ // No inCollection set initially (like Labeeb uploads)
472
+ const userFile1 = {
473
+ id: `user-file-1-${Date.now()}`,
474
+ url: 'https://example.com/user-document.pdf',
475
+ gcs: 'gs://bucket/user-document.pdf',
476
+ filename: 'user-document.pdf',
477
+ displayFilename: 'user-document.pdf',
478
+ mimeType: 'application/pdf',
479
+ hash: 'user-hash-1',
480
+ permanent: false,
481
+ timestamp: new Date().toISOString(),
482
+ // No inCollection initially
483
+ };
484
+
485
+ const userFile2 = {
486
+ id: `user-file-2-${Date.now()}`,
487
+ url: 'https://example.com/user-notes.txt',
488
+ gcs: 'gs://bucket/user-notes.txt',
489
+ filename: 'user-notes.txt',
490
+ displayFilename: 'user-notes.txt',
491
+ mimeType: 'text/plain',
492
+ hash: 'user-hash-2',
493
+ permanent: false,
494
+ timestamp: new Date().toISOString(),
495
+ // No inCollection initially
496
+ };
497
+
498
+ // Create files in workspace context (unencrypted)
499
+ // No inCollection set initially (like Labeeb uploads)
500
+ const workspaceFile1 = {
501
+ id: `workspace-file-1-${Date.now()}`,
502
+ url: 'https://example.com/workspace-shared.pdf',
503
+ gcs: 'gs://bucket/workspace-shared.pdf',
504
+ filename: 'workspace-shared.pdf',
505
+ displayFilename: 'workspace-shared.pdf',
506
+ mimeType: 'application/pdf',
507
+ hash: 'workspace-hash-1',
508
+ permanent: false,
509
+ timestamp: new Date().toISOString(),
510
+ // No inCollection initially
511
+ };
512
+
513
+ const workspaceFile2 = {
514
+ id: `workspace-file-2-${Date.now()}`,
515
+ url: 'https://example.com/workspace-data.csv',
516
+ gcs: 'gs://bucket/workspace-data.csv',
517
+ filename: 'workspace-data.csv',
518
+ displayFilename: 'workspace-data.csv',
519
+ mimeType: 'text/csv',
520
+ hash: 'workspace-hash-2',
521
+ permanent: false,
522
+ timestamp: new Date().toISOString(),
523
+ // No inCollection initially
524
+ };
525
+
526
+ // Write files to Redis with appropriate encryption
527
+ const userContextMapKey = `FileStoreMap:ctx:${userContextId}`;
528
+ const workspaceContextMapKey = `FileStoreMap:ctx:${workspaceContextId}`;
529
+
530
+ await writeFileDataToRedis(redisClient, userContextMapKey, userFile1.hash, userFile1, userContextKey);
531
+ await writeFileDataToRedis(redisClient, userContextMapKey, userFile2.hash, userFile2, userContextKey);
532
+ await writeFileDataToRedis(redisClient, workspaceContextMapKey, workspaceFile1.hash, workspaceFile1, null);
533
+ await writeFileDataToRedis(redisClient, workspaceContextMapKey, workspaceFile2.hash, workspaceFile2, null);
534
+
535
+ // Verify files exist in their respective contexts (using loadFileCollection to see all files)
536
+ const userFiles = await loadFileCollection({ contextId: userContextId, contextKey: userContextKey, default: true });
537
+ const workspaceFiles = await loadFileCollection(workspaceContextId);
538
+
539
+ t.is(userFiles.length, 2, 'User context should have 2 files');
540
+ t.is(workspaceFiles.length, 2, 'Workspace context should have 2 files');
541
+
542
+ // Verify files don't have inCollection set initially
543
+ const userFile1Before = userFiles.find(f => f.hash === userFile1.hash);
544
+ const workspaceFile1Before = workspaceFiles.find(f => f.hash === workspaceFile1.hash);
545
+ t.falsy(userFile1Before?.inCollection, 'User file 1 should not have inCollection set initially');
546
+ t.falsy(workspaceFile1Before?.inCollection, 'Workspace file 1 should not have inCollection set initially');
547
+
548
+ // Define compound agentContext (user + workspace)
549
+ const agentContext = [
550
+ { contextId: userContextId, contextKey: userContextKey, default: true }, // User context (encrypted, default)
551
+ { contextId: workspaceContextId, contextKey: null, default: false } // Workspace context (unencrypted)
552
+ ];
553
+
554
+ // Note: loadFileCollection with chatIds filters by inCollection
555
+ // Without chatIds, it returns ALL files regardless of inCollection status
556
+
557
+ // Test 1: Verify loadFileCollection with compound context returns all files
558
+ const allFilesFromBothContexts = await loadFileCollection(agentContext);
559
+ t.is(allFilesFromBothContexts.length, 4, 'Should have 4 files from both contexts');
560
+
561
+ // Verify files from both contexts are present
562
+ const hasUserFile1 = allFilesFromBothContexts.some(f => f.hash === userFile1.hash);
563
+ const hasUserFile2 = allFilesFromBothContexts.some(f => f.hash === userFile2.hash);
564
+ const hasWorkspaceFile1 = allFilesFromBothContexts.some(f => f.hash === workspaceFile1.hash);
565
+ const hasWorkspaceFile2 = allFilesFromBothContexts.some(f => f.hash === workspaceFile2.hash);
566
+
567
+ t.true(hasUserFile1, 'Compound context should include user file 1');
568
+ t.true(hasUserFile2, 'Compound context should include user file 2');
569
+ t.true(hasWorkspaceFile1, 'Compound context should include workspace file 1');
570
+ t.true(hasWorkspaceFile2, 'Compound context should include workspace file 2');
571
+
572
+ // Test 2: Test syncAndStripFilesFromChatHistory with compound context
573
+ const { syncAndStripFilesFromChatHistory } = await import('../../../../../lib/fileUtils.js');
574
+
575
+ // Create chatHistory with files from both contexts (using object format, not stringified)
576
+ const chatHistory = [{
577
+ role: 'user',
578
+ content: [
579
+ {
580
+ type: 'file',
581
+ url: userFile1.url,
582
+ gcs: userFile1.gcs,
583
+ hash: userFile1.hash,
584
+ filename: userFile1.filename
585
+ },
586
+ {
587
+ type: 'file',
588
+ url: workspaceFile1.url,
589
+ gcs: workspaceFile1.gcs,
590
+ hash: workspaceFile1.hash,
591
+ filename: workspaceFile1.filename
592
+ },
593
+ {
594
+ type: 'text',
595
+ text: 'Please describe these files.'
596
+ }
597
+ ]
598
+ }];
599
+
600
+ // Call syncAndStripFilesFromChatHistory directly with compound context
601
+ const result = await syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatId);
602
+
603
+ t.truthy(result, 'Should return result');
604
+ t.truthy(result.chatHistory, 'Should have processed chatHistory');
605
+ t.truthy(result.availableFiles, 'Should have availableFiles');
606
+
607
+ // Verify files were stripped (replaced with placeholders)
608
+ const processedContent = result.chatHistory[0].content;
609
+ const strippedUserFile = processedContent.find(c =>
610
+ c.type === 'text' && c.text && c.text.includes('user-document.pdf') && c.text.includes('available via file tools')
611
+ );
612
+ const strippedWorkspaceFile = processedContent.find(c =>
613
+ c.type === 'text' && c.text && c.text.includes('workspace-shared.pdf') && c.text.includes('available via file tools')
614
+ );
615
+
616
+ t.truthy(strippedUserFile, 'User file should be stripped from chatHistory');
617
+ t.truthy(strippedWorkspaceFile, 'Workspace file should be stripped from chatHistory');
618
+
619
+ // Wait for async metadata updates
620
+ await new Promise(resolve => setTimeout(resolve, 500));
621
+
622
+ // Test 3: Verify inCollection was updated for files in chatHistory
623
+ const userFilesAfter = await loadFileCollection({ contextId: userContextId, contextKey: userContextKey, default: true }, { useCache: false });
624
+ const userFile1After = userFilesAfter.find(f => f.hash === userFile1.hash);
625
+
626
+ const workspaceFilesAfter = await loadFileCollection(workspaceContextId, { useCache: false });
627
+ const workspaceFile1After = workspaceFilesAfter.find(f => f.hash === workspaceFile1.hash);
628
+
629
+ t.truthy(userFile1After?.inCollection, 'User file 1 should have inCollection set after sync');
630
+ t.truthy(workspaceFile1After?.inCollection, 'Workspace file 1 should have inCollection set after sync');
631
+
632
+ // Test 4: Verify merged collection with chatId filter now includes the synced files
633
+ const mergedWithChatId = await loadFileCollection(agentContext, { chatIds: [chatId], useCache: false });
634
+ t.true(mergedWithChatId.length >= 2, 'Merged collection with chatId should have at least 2 files');
635
+
636
+ const hasUserFile1AfterSync = mergedWithChatId.some(f => f.hash === userFile1.hash);
637
+ const hasWorkspaceFile1AfterSync = mergedWithChatId.some(f => f.hash === workspaceFile1.hash);
638
+
639
+ t.true(hasUserFile1AfterSync, 'Merged collection should include user file 1 after sync');
640
+ t.true(hasWorkspaceFile1AfterSync, 'Merged collection should include workspace file 1 after sync');
641
+
642
+ } finally {
643
+ // Cleanup
644
+ const redisClient = await getRedisClient();
645
+ if (redisClient) {
646
+ await redisClient.del(`FileStoreMap:ctx:${userContextId}`);
647
+ await redisClient.del(`FileStoreMap:ctx:${workspaceContextId}`);
648
+ }
649
+ }
650
+ });
651
+
652
+ test('sys_entity_agent processes real files from compound context (user + workspace) - e2e with file handler', async (t) => {
653
+ t.timeout(120000); // 2 minute timeout
654
+
655
+ const userContextId = `test-user-e2e-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
656
+ const workspaceContextId = `test-workspace-e2e-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
657
+ const chatId = `test-chat-e2e-${Date.now()}`;
658
+
659
+ try {
660
+ // Upload files to user context
661
+ const userFile1 = await uploadFileToHandler(
662
+ 'User Document Content\nThis is a document from the user context.',
663
+ 'user-document.txt',
664
+ userContextId
665
+ );
666
+
667
+ const userFile2 = await uploadFileToHandler(
668
+ 'User Notes\nThese are personal notes.',
669
+ 'user-notes.txt',
670
+ userContextId
671
+ );
672
+
673
+ // Upload files to workspace context
674
+ const workspaceFile1 = await uploadFileToHandler(
675
+ 'Workspace Shared Document\nThis is a shared document from the workspace.',
676
+ 'workspace-shared.txt',
677
+ workspaceContextId
678
+ );
679
+
680
+ const workspaceFile2 = await uploadFileToHandler(
681
+ 'Workspace Data\nThis is workspace data.',
682
+ 'workspace-data.txt',
683
+ workspaceContextId
684
+ );
685
+
686
+ // Verify files exist in Redis but don't have inCollection set
687
+ t.true(
688
+ await verifyFileInRedisWithoutInCollection(userContextId, userFile1.hash),
689
+ 'User file 1 should exist without inCollection'
690
+ );
691
+ t.true(
692
+ await verifyFileInRedisWithoutInCollection(workspaceContextId, workspaceFile1.hash),
693
+ 'Workspace file 1 should exist without inCollection'
694
+ );
695
+
696
+ // Define compound agentContext (user + workspace)
697
+ const agentContext = [
698
+ { contextId: userContextId, contextKey: null, default: true },
699
+ { contextId: workspaceContextId, contextKey: null, default: false }
700
+ ];
701
+
702
+ // Create chatHistory with files from both contexts
703
+ const chatHistory = [{
704
+ role: 'user',
705
+ content: [
706
+ JSON.stringify({
707
+ type: 'file',
708
+ url: userFile1.url,
709
+ hash: userFile1.hash,
710
+ filename: 'user-document.txt'
711
+ }),
712
+ JSON.stringify({
713
+ type: 'file',
714
+ url: workspaceFile1.url,
715
+ hash: workspaceFile1.hash,
716
+ filename: 'workspace-shared.txt'
717
+ }),
718
+ JSON.stringify({
719
+ type: 'text',
720
+ text: 'Please describe these files. One is from my user context and one is from the workspace context.'
721
+ })
722
+ ]
723
+ }];
724
+
725
+ // Call sys_entity_agent with compound context
726
+ const response = await testServer.executeOperation({
727
+ query: `
728
+ query TestCompoundContextE2E(
729
+ $text: String!,
730
+ $chatHistory: [MultiMessage]!,
731
+ $agentContext: [AgentContextInput]!,
732
+ $chatId: String
733
+ ) {
734
+ sys_entity_agent(
735
+ text: $text,
736
+ chatHistory: $chatHistory,
737
+ agentContext: $agentContext,
738
+ chatId: $chatId,
739
+ stream: true
740
+ ) {
741
+ result
742
+ contextId
743
+ tool
744
+ warnings
745
+ errors
746
+ }
747
+ }
748
+ `,
749
+ variables: {
750
+ text: 'Please describe these files.',
751
+ chatHistory: chatHistory,
752
+ agentContext: agentContext,
753
+ chatId: chatId
754
+ }
755
+ });
756
+
757
+ t.falsy(response.body?.singleResult?.errors, 'Should not have GraphQL errors');
758
+ const requestId = response.body?.singleResult?.data?.sys_entity_agent?.result;
759
+ t.truthy(requestId, 'Should have a requestId in the result field');
760
+
761
+ // Collect events
762
+ const events = await collectSubscriptionEvents({
763
+ query: `
764
+ subscription OnRequestProgress($requestId: String!) {
765
+ requestProgress(requestIds: [$requestId]) {
766
+ requestId
767
+ progress
768
+ status
769
+ data
770
+ info
771
+ error
772
+ }
773
+ }
774
+ `,
775
+ variables: { requestId }
776
+ });
777
+
778
+ t.true(events.length > 0, 'Should have received events');
779
+
780
+ // Verify completion event
781
+ const completionEvent = events.find(event =>
782
+ event.data.requestProgress.progress === 1
783
+ );
784
+ t.truthy(completionEvent, 'Should have received a completion event');
785
+
786
+ // Verify files were synced (inCollection should be set for files in chatHistory)
787
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for async updates
788
+
789
+ // Check user context files (use useCache: false to get fresh data)
790
+ const userFilesAfter = await loadFileCollection({ contextId: userContextId, contextKey: null, default: true }, { useCache: false });
791
+ const userFile1After = userFilesAfter.find(f => f.hash === userFile1.hash);
792
+ const userFile2After = userFilesAfter.find(f => f.hash === userFile2.hash);
793
+
794
+ // Check workspace context files (use useCache: false to get fresh data)
795
+ const workspaceFilesAfter = await loadFileCollection(workspaceContextId, { useCache: false });
796
+ const workspaceFile1After = workspaceFilesAfter.find(f => f.hash === workspaceFile1.hash);
797
+ const workspaceFile2After = workspaceFilesAfter.find(f => f.hash === workspaceFile2.hash);
798
+
799
+ // Files that were in chatHistory should have inCollection set
800
+ t.truthy(userFile1After?.inCollection, 'User file 1 (in chatHistory) should have inCollection set');
801
+ t.truthy(workspaceFile1After?.inCollection, 'Workspace file 1 (in chatHistory) should have inCollection set');
802
+
803
+ // Files NOT in chatHistory should still not have inCollection (they weren't accessed)
804
+ t.falsy(userFile2After?.inCollection, 'User file 2 (not in chatHistory) should not have inCollection set');
805
+ t.falsy(workspaceFile2After?.inCollection, 'Workspace file 2 (not in chatHistory) should not have inCollection set');
806
+
807
+ // Verify merged collection with chatId filter now includes the synced files
808
+ const mergedWithChatId = await loadFileCollection(agentContext, { chatIds: [chatId], useCache: false });
809
+ t.true(mergedWithChatId.length >= 2, 'Merged collection with chatId should have at least the files from chatHistory');
810
+
811
+ // Verify files from both contexts are accessible in merged collection
812
+ const hasUserFile1After = mergedWithChatId.some(f => f.hash === userFile1.hash);
813
+ const hasWorkspaceFile1After = mergedWithChatId.some(f => f.hash === workspaceFile1.hash);
814
+
815
+ t.true(hasUserFile1After, 'Merged collection should include user file 1 from user context');
816
+ t.true(hasWorkspaceFile1After, 'Merged collection should include workspace file 1 from workspace context');
817
+
818
+ // Verify the merged collection correctly combines files from both contexts
819
+ t.true(hasUserFile1After && hasWorkspaceFile1After, 'Merged collection should include files from both user and workspace contexts');
820
+
821
+ } catch (error) {
822
+ // If file handler is not configured, skip the test
823
+ if (error.message?.includes('File handler URL not configured') ||
824
+ error.message?.includes('WHISPER_MEDIA_API_URL')) {
825
+ t.log('Test skipped - file handler URL not configured');
826
+ t.pass();
827
+ return;
828
+ }
829
+ throw error;
830
+ } finally {
831
+ // Cleanup
832
+ const redisClient = await getRedisClient();
833
+ if (redisClient) {
834
+ await redisClient.del(`FileStoreMap:ctx:${userContextId}`);
835
+ await redisClient.del(`FileStoreMap:ctx:${workspaceContextId}`);
836
+ }
837
+ }
838
+ });
839
+