✂️ refactor: Artifacts and Tool Callbacks to Pass UI Resources (#9581)

* ✂️ refactor: use artifacts and callbacks to pass UI resources

* chore: imports

* refactor: Update UIResource type imports and definitions across components and tests

* refactor: Update ToolCallInfo test data structure and enhance TAttachment type definition

---------

Co-authored-by: Samuel Path <samuel.path@shopify.com>
This commit is contained in:
Danny Avila 2025-09-11 14:34:07 -04:00 committed by GitHub
parent 916742ab9d
commit 180046a3c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1072 additions and 199 deletions

View file

@ -642,10 +642,3 @@ declare global {
google_tag_manager?: unknown;
}
}
export type UIResource = {
uri: string;
mimeType: string;
text: string;
[key: string]: unknown;
};

View file

@ -211,6 +211,7 @@ export default function ToolCall({
domain={authDomain || (domain ?? '')}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
attachments={attachments}
/>
)}
</div>

View file

@ -1,8 +1,9 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from './UIResourceCarousel';
import type { UIResource } from '~/common';
import type { TAttachment, UIResource } from 'librechat-data-provider';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
return (
@ -27,12 +28,14 @@ export default function ToolCallInfo({
domain,
function_name,
pendingAuth,
attachments,
}: {
input: string;
function_name: string;
output?: string | null;
domain?: string;
pendingAuth?: boolean;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const formatText = (text: string) => {
@ -54,25 +57,12 @@ export default function ToolCallInfo({
: localize('com_assistants_attempt_info');
}
// Extract ui_resources from the output to display them in the UI
let uiResources: UIResource[] = [];
if (output?.includes('ui_resources')) {
try {
const parsedOutput = JSON.parse(output);
const uiResourcesItem = parsedOutput.find(
(contentItem) => contentItem.metadata?.type === 'ui_resources',
);
if (uiResourcesItem?.metadata?.data) {
uiResources = uiResourcesItem.metadata.data;
output = JSON.stringify(
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
);
}
} catch (error) {
// If JSON parsing fails, keep original output
console.error('Failed to parse output:', error);
}
}
const uiResources: UIResource[] =
attachments
?.filter((attachment) => attachment.type === Tools.ui_resources)
.flatMap((attachment) => {
return attachment[Tools.ui_resources] as UIResource[];
}) ?? [];
return (
<div className="w-full p-2">

View file

@ -1,6 +1,6 @@
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from '~/common';
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
interface UIResourceCarouselProps {
uiResources: UIResource[];

View file

@ -0,0 +1,382 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { Tools } from 'librechat-data-provider';
import ToolCall from '../ToolCall';
// Mock dependencies
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: any) => {
const translations: Record<string, string> = {
com_assistants_function_use: `Used ${values?.[0]}`,
com_assistants_completed_function: `Completed ${values?.[0]}`,
com_assistants_completed_action: `Completed action on ${values?.[0]}`,
com_assistants_running_var: `Running ${values?.[0]}`,
com_assistants_running_action: 'Running action',
com_ui_sign_in_to_domain: `Sign in to ${values?.[0]}`,
com_ui_cancelled: 'Cancelled',
com_ui_requires_auth: 'Requires authentication',
com_assistants_allow_sites_you_trust: 'Only allow sites you trust',
};
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
}));
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div data-testid="message-content">{content}</div>,
}));
jest.mock('../ToolCallInfo', () => ({
__esModule: true,
default: ({ attachments, ...props }: any) => (
<div data-testid="tool-call-info" data-attachments={JSON.stringify(attachments)}>
{JSON.stringify(props)}
</div>
),
}));
jest.mock('../ProgressText', () => ({
__esModule: true,
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
<div onClick={onClick}>{finishedText || inProgressText}</div>
),
}));
jest.mock('../Parts', () => ({
AttachmentGroup: ({ attachments }: any) => (
<div data-testid="attachment-group">{JSON.stringify(attachments)}</div>
),
}));
jest.mock('~/components/ui', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span>{'ChevronDown'}</span>,
ChevronUp: () => <span>{'ChevronUp'}</span>,
TriangleAlert: () => <span>{'TriangleAlert'}</span>,
}));
jest.mock('~/utils', () => ({
logger: {
error: jest.fn(),
},
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}));
describe('ToolCall', () => {
const mockProps = {
args: '{"test": "input"}',
name: 'testFunction',
output: 'Test output',
initialProgress: 1,
isSubmitting: false,
};
const renderWithRecoil = (component: React.ReactElement) => {
return render(<RecoilRoot>{component}</RecoilRoot>);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('attachments prop passing', () => {
it('should pass attachments to ToolCallInfo when provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Click me' },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
expect(toolCallInfo).toBeInTheDocument();
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBe(JSON.stringify(attachments));
});
it('should pass empty array when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBeNull(); // JSON.stringify(undefined) returns undefined, so attribute is not set
});
it('should pass multiple attachments of different types', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: {
'0': { type: 'form', fields: [] },
},
},
{
type: Tools.web_search,
messageId: 'msg2',
toolCallId: 'tool2',
conversationId: 'conv2',
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(attachments);
});
});
describe('attachment group rendering', () => {
it('should render AttachmentGroup when attachments are provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'chart', data: [] },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(attachmentGroup).toBeInTheDocument();
expect(attachmentGroup.textContent).toBe(JSON.stringify(attachments));
});
it('should not render AttachmentGroup when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
it('should not render AttachmentGroup when attachments is empty array', () => {
renderWithRecoil(<ToolCall {...mockProps} attachments={[]} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
});
describe('tool call info visibility', () => {
it('should toggle tool call info when clicking header', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
// Initially closed
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
// Click to open
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
// Click to close
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
});
it('should pass all required props to ToolCallInfo', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Test' },
},
},
];
// Use a name with domain separator (_action_) and domain separator (---)
const propsWithDomain = {
...mockProps,
name: 'testFunction_action_test---domain---com', // domain will be extracted and --- replaced with dots
attachments,
};
renderWithRecoil(<ToolCall {...propsWithDomain} />);
fireEvent.click(screen.getByText('Completed action on test.domain.com'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('{"test": "input"}');
expect(props.output).toBe('Test output');
expect(props.function_name).toBe('testFunction');
// Domain is extracted from name and --- are replaced with dots
expect(props.domain).toBe('test.domain.com');
expect(props.pendingAuth).toBe(false);
});
});
describe('authentication flow', () => {
it('should show sign-in button when auth URL is provided', () => {
const originalOpen = window.open;
window.open = jest.fn();
renderWithRecoil(
<ToolCall
{...mockProps}
initialProgress={0.5} // Less than 1 so it's not complete
auth="https://auth.example.com"
isSubmitting={true} // Should be submitting for auth to show
/>,
);
const signInButton = screen.getByText('Sign in to auth.example.com');
expect(signInButton).toBeInTheDocument();
fireEvent.click(signInButton);
expect(window.open).toHaveBeenCalledWith(
'https://auth.example.com',
'_blank',
'noopener,noreferrer',
);
window.open = originalOpen;
});
it('should pass pendingAuth as true when auth is pending', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com" // Need auth URL to extract domain
initialProgress={0.5} // Less than 1
isSubmitting={true} // Still submitting
/>,
);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.pendingAuth).toBe(true);
});
it('should not show auth section when cancelled', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={0.5}
cancelled={true}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
it('should not show auth section when progress is complete', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={1}
cancelled={false}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle undefined args', () => {
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('');
});
it('should handle null output', () => {
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.output).toBeNull();
});
it('should handle missing domain', () => {
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.domain).toBe('');
});
it('should handle complex nested attachments', () => {
const complexAttachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': {
type: 'nested',
data: {
deep: {
value: 'test',
array: [1, 2, 3],
object: { key: 'value' },
},
},
},
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(complexAttachments);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(JSON.parse(attachmentGroup.textContent!)).toEqual(complexAttachments);
});
});
});

View file

@ -1,8 +1,10 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ToolCallInfo from '../ToolCallInfo';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from '../UIResourceCarousel';
import { render, screen } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import ToolCallInfo from '~/components/Chat/Messages/Content/ToolCallInfo';
// Mock the dependencies
jest.mock('~/hooks', () => ({
@ -46,24 +48,25 @@ describe('ToolCallInfo', () => {
jest.clearAllMocks();
});
describe('ui_resources extraction', () => {
it('should extract single ui_resource from output', () => {
describe('ui_resources from attachments', () => {
it('should render single ui_resource from attachments', () => {
const uiResource = {
type: 'text',
data: 'Test resource',
};
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render UIResourceRenderer for single resource
expect(UIResourceRenderer).toHaveBeenCalledWith(
@ -81,29 +84,33 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should extract multiple ui_resources from output', () => {
const uiResources = [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
it('should render carousel for multiple ui_resources from attachments', () => {
// To test multiple resources, we can use a single attachment with multiple resources
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
},
];
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: uiResources,
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render carousel for multiple resources
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources,
uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
}),
expect.any(Object),
);
@ -112,34 +119,38 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).not.toHaveBeenCalled();
});
it('should filter out ui_resources from displayed output', () => {
const regularContent = [
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
it('should handle attachments with normal output', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }],
},
];
const output = JSON.stringify([
...regularContent,
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
{ type: 'text', text: 'Regular output 1' },
{ type: 'text', text: 'Regular output 2' },
]);
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
const { container } = render(
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
);
// Check that the displayed output doesn't contain ui_resources
// Check that the output is displayed normally
const codeBlocks = container.querySelectorAll('code');
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
expect(outputCode).toContain('Regular output 1');
expect(outputCode).toContain('Regular output 2');
expect(outputCode).not.toContain('ui_resources');
// UI resources should be rendered via attachments
expect(UIResourceRenderer).toHaveBeenCalled();
});
it('should handle output without ui_resources', () => {
it('should handle no attachments', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
@ -148,66 +159,56 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle malformed ui_resources gracefully', () => {
const output = JSON.stringify([
{
metadata: 'ui_resources', // metadata should be an object, not a string
text: 'some text content',
},
]);
it('should handle empty attachments array', () => {
const attachments: TAttachment[] = [];
// Component should not throw error and should render without UI resources
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Should render the component without crashing
expect(container).toBeTruthy();
// UIResourceCarousel should not be called since the metadata structure is invalid
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle ui_resources as plain text without breaking', () => {
const outputWithTextOnly =
'This output contains ui_resources as plain text but not as a proper structure';
it('should handle attachments with non-ui_resources type', () => {
const attachments: TAttachment[] = [
{
type: Tools.web_search as any,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.web_search]: {
organic: [],
},
},
];
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Should render normally without errors
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
expect(screen.getByText('Result')).toBeInTheDocument();
// The output text should be displayed in a code block
const codeBlocks = screen.getAllByText((content, element) => {
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
});
expect(codeBlocks.length).toBeGreaterThan(0);
// Should not render UI resources components
// Should not render UI resources components for non-ui_resources attachments
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
});
describe('rendering logic', () => {
it('should render UI Resources heading when ui_resources exist', () => {
const output = JSON.stringify([
it('should render UI Resources heading when ui_resources exist in attachments', () => {
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources section to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(screen.getByText('UI Resources')).toBeInTheDocument();
});
it('should not render UI Resources heading when no ui_resources', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />);
it('should not render UI Resources heading when no ui_resources in attachments', () => {
render(<ToolCallInfo {...mockProps} />);
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
});
@ -218,16 +219,18 @@ describe('ToolCallInfo', () => {
data: { fields: [{ name: 'test', type: 'text' }] },
};
const output = JSON.stringify([
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [uiResource],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
@ -244,16 +247,18 @@ describe('ToolCallInfo', () => {
it('should console.log when UIAction is triggered', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const output = JSON.stringify([
const attachments: TAttachment[] = [
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'Test' }],
},
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
},
]);
];
render(<ToolCallInfo {...mockProps} output={output} />);
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer
@ -270,4 +275,55 @@ describe('ToolCallInfo', () => {
consoleSpy.mockRestore();
});
});
describe('backward compatibility', () => {
it('should handle output with ui_resources for backward compatibility', () => {
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Since we now use attachments, ui_resources in output should be ignored
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should prioritize attachments over output ui_resources', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }],
},
];
const output = JSON.stringify([
{
metadata: {
type: 'ui_resources',
data: [{ type: 'output', data: 'From output' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} attachments={attachments} />);
// Should use attachments, not output
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'attachment', data: 'From attachments' },
}),
expect.any(Object),
);
});
});
});

View file

@ -1,8 +1,8 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UIResourceCarousel from '../UIResourceCarousel';
import type { UIResource } from '~/common';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({