@aj-archipelago/cortex
Version:
Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.
840 lines (733 loc) • 36.3 kB
JavaScript
// file_operations_agent.test.js
// End-to-end integration tests for file operations with sys_entity_agent
// Tests scenarios where files are uploaded directly to file handler (like Labeeb does)
// and then processed by sys_entity_agent
import test from 'ava';
import serverFactory from '../../../../../index.js';
import { createClient } from 'graphql-ws';
import ws from 'ws';
import axios from 'axios';
import FormData from 'form-data';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { loadFileCollection, getRedisClient, computeBufferHash, writeFileDataToRedis } from '../../../../../lib/fileUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let testServer;
let wsClient;
// Helper to get file handler URL from config or environment
function getFileHandlerUrl() {
// Try environment variable first
if (process.env.WHISPER_MEDIA_API_URL && process.env.WHISPER_MEDIA_API_URL !== 'null') {
return process.env.WHISPER_MEDIA_API_URL;
}
// Try config from server
const config = testServer?.config;
if (config) {
const url = config.get('whisperMediaApiUrl');
if (url && url !== 'null') {
return url;
}
}
// Default to localhost:7071 (usual file handler port)
return 'http://localhost:7071';
}
// Helper to upload file directly to file handler (like Labeeb does)
async function uploadFileToHandler(content, filename, contextId) {
const fileHandlerUrl = getFileHandlerUrl();
if (!fileHandlerUrl || fileHandlerUrl === 'null') {
throw new Error('File handler URL not configured');
}
// Create temporary file
const tempDir = path.join(__dirname, '../../../../../../temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFilePath = path.join(tempDir, `test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${filename}`);
fs.writeFileSync(tempFilePath, content);
try {
// Compute hash from content (client-side, like Labeeb does)
const contentBuffer = Buffer.from(content);
const hash = await computeBufferHash(contentBuffer);
const form = new FormData();
form.append('file', fs.createReadStream(tempFilePath), {
filename: filename,
contentType: 'application/octet-stream'
});
form.append('hash', hash); // Include hash in upload
if (contextId) {
form.append('contextId', contextId);
}
// The base URL might already include the path, or might just be the base
// Try to construct the URL correctly
let uploadUrl = fileHandlerUrl;
if (!fileHandlerUrl.includes('/api/') && !fileHandlerUrl.includes('/file-handler')) {
// Base URL doesn't include path, add the endpoint
uploadUrl = `${fileHandlerUrl}/api/CortexFileHandler`;
}
const response = await axios.post(uploadUrl, form, {
headers: {
...form.getHeaders()
},
timeout: 30000,
validateStatus: (status) => status >= 200 && status < 500
});
if (response.status !== 200 || !response.data?.url) {
throw new Error(`Upload failed: ${response.status} - ${JSON.stringify(response.data)}`);
}
// Hash should be in response since we provided it
// Wait a bit for Redis to be updated
await new Promise(resolve => setTimeout(resolve, 500));
return {
url: response.data.converted?.url || response.data.url,
gcs: response.data.converted?.gcs || response.data.gcs || null,
hash: response.data.hash
};
} finally {
// Clean up temp file
try {
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
} catch (e) {
// Ignore cleanup errors
}
}
}
// Helper to verify file exists in Redis but doesn't have inCollection set
async function verifyFileInRedisWithoutInCollection(contextId, hash) {
// Load all files (including those without inCollection)
const allFiles = await loadFileCollection(contextId);
const file = allFiles.find(f => f.hash === hash);
if (!file) return false;
// File exists but inCollection should be undefined/null
return file.inCollection === undefined || file.inCollection === null;
}
// Helper to collect subscription events
async function collectSubscriptionEvents(subscription, timeout = 60000) {
const events = [];
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (events.length > 0) {
resolve(events);
} else {
reject(new Error('Subscription timed out with no events'));
}
}, timeout);
const unsubscribe = wsClient.subscribe(
{
query: subscription.query,
variables: subscription.variables
},
{
next: (event) => {
events.push(event);
if (event?.data?.requestProgress?.progress === 1) {
clearTimeout(timeoutId);
unsubscribe();
resolve(events);
}
},
error: (error) => {
clearTimeout(timeoutId);
reject(error);
},
complete: () => {
clearTimeout(timeoutId);
resolve(events);
}
}
);
});
}
// Helper to clean up test files
async function cleanup(contextId) {
try {
const redisClient = await getRedisClient();
if (redisClient && contextId) {
const contextMapKey = `FileStoreMap:ctx:${contextId}`;
await redisClient.del(contextMapKey);
}
} catch (e) {
// Ignore cleanup errors
}
}
test.before(async () => {
process.env.CORTEX_ENABLE_REST = 'true';
const { server, startServer } = await serverFactory();
startServer && await startServer();
testServer = server;
// Create WebSocket client for subscriptions
wsClient = createClient({
url: `ws://localhost:${process.env.CORTEX_PORT || 4000}/graphql`,
webSocketImpl: ws,
retryAttempts: 3,
connectionParams: {},
on: {
error: (error) => {
console.error('WS connection error:', error);
}
}
});
// Test the connection
try {
await new Promise((resolve, reject) => {
const subscription = wsClient.subscribe(
{
query: `
subscription TestConnection {
requestProgress(requestIds: ["test"]) {
requestId
}
}
`
},
{
next: () => {
resolve();
},
error: reject,
complete: () => {
resolve();
}
}
);
setTimeout(() => {
resolve();
}, 2000);
});
} catch (error) {
console.error('Failed to establish WebSocket connection:', error);
throw error;
}
});
test.after.always('cleanup', async () => {
if (wsClient) {
wsClient.dispose();
}
if (testServer) {
await testServer.stop();
}
});
test('sys_entity_agent processes multiple files uploaded directly to file handler (no inCollection)', async (t) => {
t.timeout(120000); // 2 minute timeout
const contextId = `test-file-ops-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const chatId = `test-chat-${Date.now()}`;
try {
// Upload 3 files directly to file handler (like Labeeb does)
// These will have contextId but no inCollection set
const file1 = await uploadFileToHandler(
'File 1 Content\nThis is the first test file with some content.',
'test-file-1.txt',
contextId
);
const file2 = await uploadFileToHandler(
'File 2 Content\nThis is the second test file with different content.',
'test-file-2.txt',
contextId
);
const file3 = await uploadFileToHandler(
'File 3 Content\nThis is the third test file with more content.',
'test-file-3.txt',
contextId
);
t.truthy(file1.hash, 'File 1 should have a hash');
t.truthy(file2.hash, 'File 2 should have a hash');
t.truthy(file3.hash, 'File 3 should have a hash');
// Verify files exist in Redis but don't have inCollection set
t.true(await verifyFileInRedisWithoutInCollection(contextId, file1.hash), 'File 1 should exist in Redis without inCollection');
t.true(await verifyFileInRedisWithoutInCollection(contextId, file2.hash), 'File 2 should exist in Redis without inCollection');
t.true(await verifyFileInRedisWithoutInCollection(contextId, file3.hash), 'File 3 should exist in Redis without inCollection');
// Wait a bit for Redis to be fully updated
await new Promise(resolve => setTimeout(resolve, 1000));
// Create chatHistory with all 3 files
// MultiMessage content must be array of JSON strings
const chatHistory = [{
role: 'user',
content: [
JSON.stringify({
type: 'file',
url: file1.url,
gcs: file1.gcs,
hash: file1.hash,
filename: 'test-file-1.txt'
}),
JSON.stringify({
type: 'file',
url: file2.url,
gcs: file2.gcs,
hash: file2.hash,
filename: 'test-file-2.txt'
}),
JSON.stringify({
type: 'file',
url: file3.url,
gcs: file3.gcs,
hash: file3.hash,
filename: 'test-file-3.txt'
}),
JSON.stringify({
type: 'text',
text: 'Please read all three files and tell me the content of each file. List them as File 1, File 2, and File 3.'
})
]
}];
// Call sys_entity_agent
const response = await testServer.executeOperation({
query: `
query TestFileOperations(
$text: String!,
$chatHistory: [MultiMessage]!,
$contextId: String!,
$chatId: String
) {
sys_entity_agent(
text: $text,
chatHistory: $chatHistory,
contextId: $contextId,
chatId: $chatId,
stream: true
) {
result
contextId
tool
warnings
errors
}
}
`,
variables: {
text: 'Please read all three files and tell me the content of each file. List them as File 1, File 2, and File 3.',
chatHistory: chatHistory,
contextId: contextId,
chatId: chatId
}
});
t.falsy(response.body?.singleResult?.errors, 'Should not have GraphQL errors');
const requestId = response.body?.singleResult?.data?.sys_entity_agent?.result;
t.truthy(requestId, 'Should have a requestId in the result field');
// Collect events
const events = await collectSubscriptionEvents({
query: `
subscription OnRequestProgress($requestId: String!) {
requestProgress(requestIds: [$requestId]) {
requestId
progress
data
info
}
}
`,
variables: { requestId }
}, 120000);
t.true(events.length > 0, 'Should have received events');
// Verify we got a completion event
const completionEvent = events.find(event =>
event.data.requestProgress.progress === 1
);
t.truthy(completionEvent, 'Should have received a completion event');
// Check the response data for file content
const responseData = completionEvent.data.requestProgress.data;
t.truthy(responseData, 'Should have response data');
// Parse the data to check for file content
let parsedData;
try {
parsedData = typeof responseData === 'string' ? JSON.parse(responseData) : responseData;
} catch (e) {
// If not JSON, treat as string
parsedData = responseData;
}
const responseText = typeof parsedData === 'string' ? parsedData : JSON.stringify(parsedData);
// Verify all three files were processed
// Check that the agent actually read the files by looking for content from the files
// File 1 content: "Content of test file 1"
// File 2 content: "Content of test file 2"
// File 3 content: "Content of test file 3"
// The agent should mention at least some content from the files
const hasFile1Content = responseText.includes('test file 1') || responseText.includes('Content of test file 1') ||
responseText.includes('File 1') || responseText.includes('file 1') || responseText.includes('first');
const hasFile2Content = responseText.includes('test file 2') || responseText.includes('Content of test file 2') ||
responseText.includes('File 2') || responseText.includes('file 2') || responseText.includes('second');
const hasFile3Content = responseText.includes('test file 3') || responseText.includes('Content of test file 3') ||
responseText.includes('File 3') || responseText.includes('file 3') || responseText.includes('third');
// At minimum, verify the response is non-empty and the agent processed the request
t.truthy(responseText && responseText.length > 0, 'Agent should return a response');
// Log the response for debugging if assertions fail
if (!hasFile1Content || !hasFile2Content || !hasFile3Content) {
console.log('Agent response:', responseText.substring(0, 500));
}
// Note: We primarily verify file processing via inCollection checks below
// The response text check is secondary - the key is that files were synced
// Verify files now have inCollection set (they should be synced)
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for async updates
const allFiles = await loadFileCollection(contextId);
const file1InCollection = allFiles.find(f => f.hash === file1.hash);
const file2InCollection = allFiles.find(f => f.hash === file2.hash);
const file3InCollection = allFiles.find(f => f.hash === file3.hash);
t.truthy(file1InCollection, 'File 1 should be in collection after sync');
t.truthy(file2InCollection, 'File 2 should be in collection after sync');
t.truthy(file3InCollection, 'File 3 should be in collection after sync');
// Verify inCollection is set (should have chatId or be global)
t.truthy(file1InCollection.inCollection, 'File 1 should have inCollection set');
t.truthy(file2InCollection.inCollection, 'File 2 should have inCollection set');
t.truthy(file3InCollection.inCollection, 'File 3 should have inCollection set');
// Verify inCollection includes the chatId or is global
const hasChatId = (inCollection) => {
if (inCollection === true) return true; // Global
if (Array.isArray(inCollection)) {
return inCollection.includes('*') || inCollection.includes(chatId);
}
return false;
};
t.true(hasChatId(file1InCollection.inCollection), 'File 1 inCollection should include chatId or be global');
t.true(hasChatId(file2InCollection.inCollection), 'File 2 inCollection should include chatId or be global');
t.true(hasChatId(file3InCollection.inCollection), 'File 3 inCollection should include chatId or be global');
} catch (error) {
// If file handler is not configured, skip the test
if (error.message?.includes('File handler URL not configured') ||
error.message?.includes('WHISPER_MEDIA_API_URL')) {
t.log('Test skipped - file handler URL not configured');
t.pass();
return;
}
throw error;
} finally {
await cleanup(contextId);
}
});
test('sys_entity_agent processes files from compound context (user + workspace)', async t => {
// Compound context: user context (encrypted) + workspace context (unencrypted)
// This simulates a workspace being run by a user, where:
// - User context has encrypted files (user's personal files)
// - Workspace context has unencrypted files (shared workspace files)
// - Both should be accessible when agentContext includes both
const userContextId = `test-user-${Date.now()}`;
const workspaceContextId = `test-workspace-${Date.now()}`;
const userContextKey = 'test-user-encryption-key-12345'; // Simulated encryption key
const chatId = `test-chat-${Date.now()}`;
try {
const redisClient = await getRedisClient();
if (!redisClient) {
t.skip('Redis not available');
return;
}
// Create files in user context (encrypted)
// No inCollection set initially (like Labeeb uploads)
const userFile1 = {
id: `user-file-1-${Date.now()}`,
url: 'https://example.com/user-document.pdf',
gcs: 'gs://bucket/user-document.pdf',
filename: 'user-document.pdf',
displayFilename: 'user-document.pdf',
mimeType: 'application/pdf',
hash: 'user-hash-1',
permanent: false,
timestamp: new Date().toISOString(),
// No inCollection initially
};
const userFile2 = {
id: `user-file-2-${Date.now()}`,
url: 'https://example.com/user-notes.txt',
gcs: 'gs://bucket/user-notes.txt',
filename: 'user-notes.txt',
displayFilename: 'user-notes.txt',
mimeType: 'text/plain',
hash: 'user-hash-2',
permanent: false,
timestamp: new Date().toISOString(),
// No inCollection initially
};
// Create files in workspace context (unencrypted)
// No inCollection set initially (like Labeeb uploads)
const workspaceFile1 = {
id: `workspace-file-1-${Date.now()}`,
url: 'https://example.com/workspace-shared.pdf',
gcs: 'gs://bucket/workspace-shared.pdf',
filename: 'workspace-shared.pdf',
displayFilename: 'workspace-shared.pdf',
mimeType: 'application/pdf',
hash: 'workspace-hash-1',
permanent: false,
timestamp: new Date().toISOString(),
// No inCollection initially
};
const workspaceFile2 = {
id: `workspace-file-2-${Date.now()}`,
url: 'https://example.com/workspace-data.csv',
gcs: 'gs://bucket/workspace-data.csv',
filename: 'workspace-data.csv',
displayFilename: 'workspace-data.csv',
mimeType: 'text/csv',
hash: 'workspace-hash-2',
permanent: false,
timestamp: new Date().toISOString(),
// No inCollection initially
};
// Write files to Redis with appropriate encryption
const userContextMapKey = `FileStoreMap:ctx:${userContextId}`;
const workspaceContextMapKey = `FileStoreMap:ctx:${workspaceContextId}`;
await writeFileDataToRedis(redisClient, userContextMapKey, userFile1.hash, userFile1, userContextKey);
await writeFileDataToRedis(redisClient, userContextMapKey, userFile2.hash, userFile2, userContextKey);
await writeFileDataToRedis(redisClient, workspaceContextMapKey, workspaceFile1.hash, workspaceFile1, null);
await writeFileDataToRedis(redisClient, workspaceContextMapKey, workspaceFile2.hash, workspaceFile2, null);
// Verify files exist in their respective contexts (using loadFileCollection to see all files)
const userFiles = await loadFileCollection({ contextId: userContextId, contextKey: userContextKey, default: true });
const workspaceFiles = await loadFileCollection(workspaceContextId);
t.is(userFiles.length, 2, 'User context should have 2 files');
t.is(workspaceFiles.length, 2, 'Workspace context should have 2 files');
// Verify files don't have inCollection set initially
const userFile1Before = userFiles.find(f => f.hash === userFile1.hash);
const workspaceFile1Before = workspaceFiles.find(f => f.hash === workspaceFile1.hash);
t.falsy(userFile1Before?.inCollection, 'User file 1 should not have inCollection set initially');
t.falsy(workspaceFile1Before?.inCollection, 'Workspace file 1 should not have inCollection set initially');
// Define compound agentContext (user + workspace)
const agentContext = [
{ contextId: userContextId, contextKey: userContextKey, default: true }, // User context (encrypted, default)
{ contextId: workspaceContextId, contextKey: null, default: false } // Workspace context (unencrypted)
];
// Note: loadFileCollection with chatIds filters by inCollection
// Without chatIds, it returns ALL files regardless of inCollection status
// Test 1: Verify loadFileCollection with compound context returns all files
const allFilesFromBothContexts = await loadFileCollection(agentContext);
t.is(allFilesFromBothContexts.length, 4, 'Should have 4 files from both contexts');
// Verify files from both contexts are present
const hasUserFile1 = allFilesFromBothContexts.some(f => f.hash === userFile1.hash);
const hasUserFile2 = allFilesFromBothContexts.some(f => f.hash === userFile2.hash);
const hasWorkspaceFile1 = allFilesFromBothContexts.some(f => f.hash === workspaceFile1.hash);
const hasWorkspaceFile2 = allFilesFromBothContexts.some(f => f.hash === workspaceFile2.hash);
t.true(hasUserFile1, 'Compound context should include user file 1');
t.true(hasUserFile2, 'Compound context should include user file 2');
t.true(hasWorkspaceFile1, 'Compound context should include workspace file 1');
t.true(hasWorkspaceFile2, 'Compound context should include workspace file 2');
// Test 2: Test syncAndStripFilesFromChatHistory with compound context
const { syncAndStripFilesFromChatHistory } = await import('../../../../../lib/fileUtils.js');
// Create chatHistory with files from both contexts (using object format, not stringified)
const chatHistory = [{
role: 'user',
content: [
{
type: 'file',
url: userFile1.url,
gcs: userFile1.gcs,
hash: userFile1.hash,
filename: userFile1.filename
},
{
type: 'file',
url: workspaceFile1.url,
gcs: workspaceFile1.gcs,
hash: workspaceFile1.hash,
filename: workspaceFile1.filename
},
{
type: 'text',
text: 'Please describe these files.'
}
]
}];
// Call syncAndStripFilesFromChatHistory directly with compound context
const result = await syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatId);
t.truthy(result, 'Should return result');
t.truthy(result.chatHistory, 'Should have processed chatHistory');
t.truthy(result.availableFiles, 'Should have availableFiles');
// Verify files were stripped (replaced with placeholders)
const processedContent = result.chatHistory[0].content;
const strippedUserFile = processedContent.find(c =>
c.type === 'text' && c.text && c.text.includes('user-document.pdf') && c.text.includes('available via file tools')
);
const strippedWorkspaceFile = processedContent.find(c =>
c.type === 'text' && c.text && c.text.includes('workspace-shared.pdf') && c.text.includes('available via file tools')
);
t.truthy(strippedUserFile, 'User file should be stripped from chatHistory');
t.truthy(strippedWorkspaceFile, 'Workspace file should be stripped from chatHistory');
// Wait for async metadata updates
await new Promise(resolve => setTimeout(resolve, 500));
// Test 3: Verify inCollection was updated for files in chatHistory
const userFilesAfter = await loadFileCollection({ contextId: userContextId, contextKey: userContextKey, default: true }, { useCache: false });
const userFile1After = userFilesAfter.find(f => f.hash === userFile1.hash);
const workspaceFilesAfter = await loadFileCollection(workspaceContextId, { useCache: false });
const workspaceFile1After = workspaceFilesAfter.find(f => f.hash === workspaceFile1.hash);
t.truthy(userFile1After?.inCollection, 'User file 1 should have inCollection set after sync');
t.truthy(workspaceFile1After?.inCollection, 'Workspace file 1 should have inCollection set after sync');
// Test 4: Verify merged collection with chatId filter now includes the synced files
const mergedWithChatId = await loadFileCollection(agentContext, { chatIds: [chatId], useCache: false });
t.true(mergedWithChatId.length >= 2, 'Merged collection with chatId should have at least 2 files');
const hasUserFile1AfterSync = mergedWithChatId.some(f => f.hash === userFile1.hash);
const hasWorkspaceFile1AfterSync = mergedWithChatId.some(f => f.hash === workspaceFile1.hash);
t.true(hasUserFile1AfterSync, 'Merged collection should include user file 1 after sync');
t.true(hasWorkspaceFile1AfterSync, 'Merged collection should include workspace file 1 after sync');
} finally {
// Cleanup
const redisClient = await getRedisClient();
if (redisClient) {
await redisClient.del(`FileStoreMap:ctx:${userContextId}`);
await redisClient.del(`FileStoreMap:ctx:${workspaceContextId}`);
}
}
});
test('sys_entity_agent processes real files from compound context (user + workspace) - e2e with file handler', async (t) => {
t.timeout(120000); // 2 minute timeout
const userContextId = `test-user-e2e-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const workspaceContextId = `test-workspace-e2e-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const chatId = `test-chat-e2e-${Date.now()}`;
try {
// Upload files to user context
const userFile1 = await uploadFileToHandler(
'User Document Content\nThis is a document from the user context.',
'user-document.txt',
userContextId
);
const userFile2 = await uploadFileToHandler(
'User Notes\nThese are personal notes.',
'user-notes.txt',
userContextId
);
// Upload files to workspace context
const workspaceFile1 = await uploadFileToHandler(
'Workspace Shared Document\nThis is a shared document from the workspace.',
'workspace-shared.txt',
workspaceContextId
);
const workspaceFile2 = await uploadFileToHandler(
'Workspace Data\nThis is workspace data.',
'workspace-data.txt',
workspaceContextId
);
// Verify files exist in Redis but don't have inCollection set
t.true(
await verifyFileInRedisWithoutInCollection(userContextId, userFile1.hash),
'User file 1 should exist without inCollection'
);
t.true(
await verifyFileInRedisWithoutInCollection(workspaceContextId, workspaceFile1.hash),
'Workspace file 1 should exist without inCollection'
);
// Define compound agentContext (user + workspace)
const agentContext = [
{ contextId: userContextId, contextKey: null, default: true },
{ contextId: workspaceContextId, contextKey: null, default: false }
];
// Create chatHistory with files from both contexts
const chatHistory = [{
role: 'user',
content: [
JSON.stringify({
type: 'file',
url: userFile1.url,
hash: userFile1.hash,
filename: 'user-document.txt'
}),
JSON.stringify({
type: 'file',
url: workspaceFile1.url,
hash: workspaceFile1.hash,
filename: 'workspace-shared.txt'
}),
JSON.stringify({
type: 'text',
text: 'Please describe these files. One is from my user context and one is from the workspace context.'
})
]
}];
// Call sys_entity_agent with compound context
const response = await testServer.executeOperation({
query: `
query TestCompoundContextE2E(
$text: String!,
$chatHistory: [MultiMessage]!,
$agentContext: [AgentContextInput]!,
$chatId: String
) {
sys_entity_agent(
text: $text,
chatHistory: $chatHistory,
agentContext: $agentContext,
chatId: $chatId,
stream: true
) {
result
contextId
tool
warnings
errors
}
}
`,
variables: {
text: 'Please describe these files.',
chatHistory: chatHistory,
agentContext: agentContext,
chatId: chatId
}
});
t.falsy(response.body?.singleResult?.errors, 'Should not have GraphQL errors');
const requestId = response.body?.singleResult?.data?.sys_entity_agent?.result;
t.truthy(requestId, 'Should have a requestId in the result field');
// Collect events
const events = await collectSubscriptionEvents({
query: `
subscription OnRequestProgress($requestId: String!) {
requestProgress(requestIds: [$requestId]) {
requestId
progress
status
data
info
error
}
}
`,
variables: { requestId }
});
t.true(events.length > 0, 'Should have received events');
// Verify completion event
const completionEvent = events.find(event =>
event.data.requestProgress.progress === 1
);
t.truthy(completionEvent, 'Should have received a completion event');
// Verify files were synced (inCollection should be set for files in chatHistory)
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for async updates
// Check user context files (use useCache: false to get fresh data)
const userFilesAfter = await loadFileCollection({ contextId: userContextId, contextKey: null, default: true }, { useCache: false });
const userFile1After = userFilesAfter.find(f => f.hash === userFile1.hash);
const userFile2After = userFilesAfter.find(f => f.hash === userFile2.hash);
// Check workspace context files (use useCache: false to get fresh data)
const workspaceFilesAfter = await loadFileCollection(workspaceContextId, { useCache: false });
const workspaceFile1After = workspaceFilesAfter.find(f => f.hash === workspaceFile1.hash);
const workspaceFile2After = workspaceFilesAfter.find(f => f.hash === workspaceFile2.hash);
// Files that were in chatHistory should have inCollection set
t.truthy(userFile1After?.inCollection, 'User file 1 (in chatHistory) should have inCollection set');
t.truthy(workspaceFile1After?.inCollection, 'Workspace file 1 (in chatHistory) should have inCollection set');
// Files NOT in chatHistory should still not have inCollection (they weren't accessed)
t.falsy(userFile2After?.inCollection, 'User file 2 (not in chatHistory) should not have inCollection set');
t.falsy(workspaceFile2After?.inCollection, 'Workspace file 2 (not in chatHistory) should not have inCollection set');
// Verify merged collection with chatId filter now includes the synced files
const mergedWithChatId = await loadFileCollection(agentContext, { chatIds: [chatId], useCache: false });
t.true(mergedWithChatId.length >= 2, 'Merged collection with chatId should have at least the files from chatHistory');
// Verify files from both contexts are accessible in merged collection
const hasUserFile1After = mergedWithChatId.some(f => f.hash === userFile1.hash);
const hasWorkspaceFile1After = mergedWithChatId.some(f => f.hash === workspaceFile1.hash);
t.true(hasUserFile1After, 'Merged collection should include user file 1 from user context');
t.true(hasWorkspaceFile1After, 'Merged collection should include workspace file 1 from workspace context');
// Verify the merged collection correctly combines files from both contexts
t.true(hasUserFile1After && hasWorkspaceFile1After, 'Merged collection should include files from both user and workspace contexts');
} catch (error) {
// If file handler is not configured, skip the test
if (error.message?.includes('File handler URL not configured') ||
error.message?.includes('WHISPER_MEDIA_API_URL')) {
t.log('Test skipped - file handler URL not configured');
t.pass();
return;
}
throw error;
} finally {
// Cleanup
const redisClient = await getRedisClient();
if (redisClient) {
await redisClient.del(`FileStoreMap:ctx:${userContextId}`);
await redisClient.del(`FileStoreMap:ctx:${workspaceContextId}`);
}
}
});