@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.
- package/lib/fileUtils.js +194 -187
- package/lib/pathwayManager.js +7 -0
- package/lib/pathwayTools.js +71 -0
- package/package.json +1 -1
- package/pathways/system/entity/files/sys_read_file_collection.js +3 -3
- package/pathways/system/entity/sys_entity_agent.js +41 -3
- package/pathways/system/entity/tools/sys_tool_analyzefile.js +48 -19
- package/pathways/system/entity/tools/sys_tool_editfile.js +4 -4
- package/pathways/system/entity/tools/sys_tool_file_collection.js +24 -17
- package/pathways/system/entity/tools/sys_tool_view_image.js +3 -3
- package/server/clientToolCallbacks.js +241 -0
- package/server/executeWorkspace.js +7 -0
- package/server/graphql.js +3 -1
- package/server/plugins/gemini15VisionPlugin.js +16 -3
- package/server/resolver.js +37 -2
- package/tests/integration/clientToolCallbacks.test.js +161 -0
- package/tests/integration/features/tools/fileCollection.test.js +696 -63
- package/tests/integration/features/tools/writefile.test.js +4 -4
- package/tests/integration/graphql/async/stream/file_operations_agent.test.js +839 -0
- package/tests/unit/core/fileCollection.test.js +1 -1
- package/tests/unit/plugins/multimodal_conversion.test.js +16 -6
|
@@ -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
|
+
|