mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
* 💻 feat: deeper MCP UI integration in the chat UI using plugins --------- Co-authored-by: Samuel Path <samuel.path@shopify.com> Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com> * 💻 refactor: Migrate MCP UI resources from index-based to ID-based referencing - Replace index-based resource markers with stable resource IDs - Update plugin to parse \ui{resourceId} format instead of \ui0 - Refactor components to use useMessagesOperations instead of useSubmitMessage - Add ShareMessagesProvider for UI resources in share view - Add useConversationUIResources hook for cross-turn resource lookups - Update parsers to generate resource IDs from content hashes - Update all tests to use resource IDs instead of indices - Add sandbox permissions for iframe popups - Remove deprecated MCP tool context instructions --------- Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com>
332 lines
8.9 KiB
JavaScript
332 lines
8.9 KiB
JavaScript
const { Tools } = require('librechat-data-provider');
|
|
|
|
// Mock all dependencies before requiring the module
|
|
jest.mock('nanoid', () => ({
|
|
nanoid: jest.fn(() => 'mock-id'),
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
sendEvent: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
error: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@librechat/agents', () => ({
|
|
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
|
|
Providers: { GOOGLE: 'google' },
|
|
GraphEvents: {},
|
|
getMessageId: jest.fn(),
|
|
ToolEndHandler: jest.fn(),
|
|
handleToolCalls: jest.fn(),
|
|
ChatModelStreamHandler: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Citations', () => ({
|
|
processFileCitations: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
|
processCodeOutput: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
saveBase64Image: jest.fn(),
|
|
}));
|
|
|
|
describe('createToolEndCallback', () => {
|
|
let req, res, artifactPromises, createToolEndCallback;
|
|
let logger;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Get the mocked logger
|
|
logger = require('@librechat/data-schemas').logger;
|
|
|
|
// Now require the module after all mocks are set up
|
|
const callbacks = require('../callbacks');
|
|
createToolEndCallback = callbacks.createToolEndCallback;
|
|
|
|
req = {
|
|
user: { id: 'user123' },
|
|
};
|
|
res = {
|
|
headersSent: false,
|
|
write: jest.fn(),
|
|
};
|
|
artifactPromises = [];
|
|
});
|
|
|
|
describe('ui_resources artifact handling', () => {
|
|
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [
|
|
{ type: 'button', label: 'Click me' },
|
|
{ type: 'input', placeholder: 'Enter text' },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
|
|
// Wait for all promises to resolve
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
// When headers are not sent, it returns attachment without writing
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
|
|
const attachment = results[0];
|
|
expect(attachment).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [
|
|
{ type: 'button', label: 'Click me' },
|
|
{ type: 'input', placeholder: 'Enter text' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should write to response when headers are already sent', async () => {
|
|
res.headersSent = true;
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'carousel', items: [] }],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(res.write).toHaveBeenCalled();
|
|
expect(results[0]).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [{ type: 'carousel', items: [] }],
|
|
});
|
|
});
|
|
|
|
it('should handle errors when processing ui_resources', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
// Mock res.write to throw an error
|
|
res.headersSent = true;
|
|
res.write.mockImplementation(() => {
|
|
throw new Error('Write failed');
|
|
});
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'test' }],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error processing artifact content:',
|
|
expect.any(Error),
|
|
);
|
|
expect(results[0]).toBeNull();
|
|
});
|
|
|
|
it('should handle multiple artifacts including ui_resources', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'chart', data: [] }],
|
|
},
|
|
[Tools.web_search]: {
|
|
results: ['result1', 'result2'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
// Both ui_resources and web_search should be processed
|
|
expect(artifactPromises).toHaveLength(2);
|
|
expect(results).toHaveLength(2);
|
|
|
|
// Check ui_resources attachment
|
|
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
|
expect(uiResourceAttachment).toBeTruthy();
|
|
expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
|
|
|
|
// Check web_search attachment
|
|
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
|
expect(webSearchAttachment).toBeTruthy();
|
|
expect(webSearchAttachment[Tools.web_search]).toEqual({
|
|
results: ['result1', 'result2'],
|
|
});
|
|
});
|
|
|
|
it('should not process artifacts when output has no artifacts', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
content: 'Some regular content',
|
|
// No artifact property
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty ui_resources data object', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(results[0]).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [],
|
|
});
|
|
});
|
|
|
|
it('should handle ui_resources with complex nested data', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const complexData = {
|
|
0: {
|
|
type: 'form',
|
|
fields: [
|
|
{ name: 'field1', type: 'text', required: true },
|
|
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
|
|
],
|
|
nested: {
|
|
deep: {
|
|
value: 123,
|
|
array: [1, 2, 3],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: complexData,
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(results[0][Tools.ui_resources]).toEqual(complexData);
|
|
});
|
|
|
|
it('should handle when output is undefined', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output: undefined }, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle when data parameter is undefined', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback(undefined, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|