diff --git a/client/src/components/Chat/Input/Composer/Palette.tsx b/client/src/components/Chat/Input/Composer/Palette.tsx index 6c15072963..e7bb175fdf 100644 --- a/client/src/components/Chat/Input/Composer/Palette.tsx +++ b/client/src/components/Chat/Input/Composer/Palette.tsx @@ -468,7 +468,14 @@ function Palette({ const before = previousTops.current; previousTops.current = layout.tops; const body = listBodyRef.current; - if (instant || body == null || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + /* Where there is no Web Animations API to play the move with, the rows just + arrive where they belong — the same as asking for no motion. */ + if ( + instant || + body == null || + typeof body.animate !== 'function' || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { return; } for (const element of body.querySelectorAll('[data-row-key]')) { diff --git a/client/src/components/Chat/Input/Composer/__tests__/Palette.spec.tsx b/client/src/components/Chat/Input/Composer/__tests__/Palette.spec.tsx new file mode 100644 index 0000000000..b027ded071 --- /dev/null +++ b/client/src/components/Chat/Input/Composer/__tests__/Palette.spec.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { TConversation } from 'librechat-data-provider'; +import type { PaletteEntry } from '~/hooks/Input/usePaletteEntries'; +import type { AttachEntry } from '~/hooks/Input/useAttachItems'; +import Palette from '../Palette'; + +/** + * The palette's row model: what the list is made of, in what order, and what a + * search does to it. The list is virtualized and the motion is measured in a + * browser, so this covers the part that decides rows rather than draws them. + */ + +/* AutoSizer measures its parent, which is zero in jsdom, and a zero width + renders no rows at all. */ +jest.mock('react-virtualized', () => { + const actual = jest.requireActual('react-virtualized'); + return { + ...actual, + AutoSizer: ({ children }: { children: (size: { width: number }) => React.ReactNode }) => + children({ width: 640 }), + }; +}); + +jest.mock('~/hooks/Generic/useElementSize', () => ({ + __esModule: true, + default: () => ({ ref: { current: null }, height: 0, width: 0 }), +})); + +jest.mock('~/components/SharePoint', () => ({ SharePointPickerDialog: () => null })); + +const mockToggleFavorite = jest.fn(); +jest.mock('~/hooks/Input/useToolFavorites', () => ({ + __esModule: true, + default: () => ({ + keys: new Set(mockFavoriteKeys), + toggleFavorite: mockToggleFavorite, + isFavorite: (key: string) => mockFavoriteKeys.includes(key), + }), +})); + +jest.mock('~/hooks/Input/useRecentFiles', () => ({ + __esModule: true, + default: () => ({ files: mockRecentFiles, attach: jest.fn() }), +})); + +jest.mock('~/hooks/Input/useAttachItems', () => ({ + __esModule: true, + default: () => ({ + entries: mockAttachEntries, + inputRef: { current: null }, + onFileChange: jest.fn(), + isSharePointDialogOpen: false, + setIsSharePointDialogOpen: jest.fn(), + onSharePointFilesSelected: jest.fn(), + isProcessing: false, + downloadProgress: undefined, + maxSelectionCount: 10, + }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string, options?: Record) => + options ? `${key}:${options['0'] ?? options.count}` : key, +})); + +let mockFavoriteKeys: string[] = []; +let mockRecentFiles: Array> = []; +let mockAttachEntries: AttachEntry[] = []; + +const attachEntry = (id: string, label: string, primary = false): AttachEntry => ({ + id, + label, + primary, + icon: null, + onSelect: jest.fn(), +}); + +const entry = (over: Partial & Pick): PaletteEntry => ({ + itemType: 'tool', + itemId: over.key, + label: over.key, + icon: null, + section: 'tool', + active: false, + onSelect: jest.fn(), + ...over, +}); + +const ENTRIES: PaletteEntry[] = [ + entry({ key: 'web_search', label: 'Web Search', section: 'tool' }), + entry({ key: 'execute_code', label: 'Run Code', section: 'tool' }), + entry({ key: 'skill:writer', label: 'writing-helper', section: 'skill', description: 'Drafts' }), + entry({ key: 'mcp:github', label: 'Github', section: 'mcp' }), +]; + +const ATTACH: AttachEntry[] = [ + attachEntry('local:provider', 'Upload to Provider', true), + attachEntry('local:context', 'Upload as Text'), + attachEntry('local:file_search', 'Upload for File Search'), + attachEntry('local:execute_code', 'Upload to Code Environment'), +]; + +function renderPalette(over: { canAttach?: boolean; entries?: PaletteEntry[] } = {}) { + const anchorRef = { current: document.createElement('div') }; + const view = render( + + } + /> + , + ); + fireEvent.click(screen.getByTestId('composer-palette-button')); + return view; +} + +/** Row labels in list order, headers included, as the user reads them. */ +const rows = () => + Array.from( + document.querySelectorAll( + '#composer-palette-list [role="option"], #composer-palette-list [role="presentation"]', + ), + ).map((row) => row.textContent?.trim() ?? ''); + +/** Just the section headers, which is what carries the order. */ +const headers = () => + Array.from(document.querySelectorAll('#composer-palette-list [role="presentation"]')).map( + (row) => row.textContent?.trim() ?? '', + ); + +/** Row identities in list order, which is what the model actually decides. */ +const keys = () => + Array.from(document.querySelectorAll('#composer-palette-list [data-row-key]')).map( + (row) => row.dataset.rowKey ?? '', + ); + +const search = (text: string) => { + const input = screen.getByTestId('composer-palette-search'); + fireEvent.change(input, { target: { value: text } }); +}; + +describe('Palette', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFavoriteKeys = []; + mockRecentFiles = []; + mockAttachEntries = ATTACH; + }); + + describe('section order', () => { + it('reads attach, tools, skills, servers, files', () => { + mockRecentFiles = [{ file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }]; + renderPalette(); + expect(headers()).toEqual([ + 'com_ui_composer_attach', + 'com_ui_composer_tools', + 'com_ui_skills', + 'com_ui_composer_mcp', + 'com_ui_composer_files', + ]); + }); + + it('puts favourites above everything, out of their own sections', () => { + mockFavoriteKeys = ['mcp:github']; + renderPalette(); + const listed = rows(); + expect(listed[0]).toBe('com_ui_tools_view_favorites'); + expect(listed[1]).toBe('Github'); + /* And not left behind in the section it came from. */ + expect(listed.filter((row) => row === 'Github')).toHaveLength(1); + }); + + it('drops a section whose only member was starred', () => { + mockFavoriteKeys = ['mcp:github']; + renderPalette(); + expect(rows()).not.toContain('com_ui_composer_mcp'); + }); + + it('offers no upload destinations when the endpoint cannot take them', () => { + renderPalette({ canAttach: false }); + expect(rows()).not.toContain('com_ui_composer_attach'); + expect(rows()).not.toContain('Upload to Provider'); + }); + }); + + describe('the folded upload destinations', () => { + it('shows the ordinary destination and folds the rest away', () => { + renderPalette(); + const listed = rows(); + expect(listed).toContain('Upload to Provider'); + expect(listed).toContain('com_ui_composer_attach_more'); + expect(listed).not.toContain('Upload to Code Environment'); + }); + + it('reveals them in place, and folds them back', async () => { + renderPalette(); + fireEvent.click(screen.getByText('com_ui_composer_attach_more')); + expect(rows()).toContain('Upload to Code Environment'); + expect(rows()).toContain('com_ui_composer_attach_less'); + + fireEvent.click(screen.getByText('com_ui_composer_attach_less')); + /* Closing waits for the rows to fade before taking their space back, so + the list still holds them for the length of that fade. */ + expect(rows()).toContain('Upload to Code Environment'); + await waitFor(() => expect(rows()).not.toContain('Upload to Code Environment')); + }); + + it('offers no disclosure when there is nothing to fold', () => { + mockAttachEntries = [attachEntry('local:provider', 'Upload to Provider', true)]; + renderPalette(); + expect(rows()).toContain('Upload to Provider'); + expect(rows()).not.toContain('com_ui_composer_attach_more'); + }); + }); + + describe('searching', () => { + it('reaches a folded destination by name', () => { + renderPalette(); + search('code environment'); + const listed = rows(); + expect(listed).toContain('Upload to Code Environment'); + /* The disclosure is there to keep the resting list short, not to make a + row unfindable, so it steps out of the way of a search. */ + expect(listed).not.toContain('com_ui_composer_attach_more'); + }); + + it('drops sections with nothing left in them', () => { + renderPalette(); + search('github'); + expect(keys()).toEqual(['h:mcp', 'mcp:github']); + }); + + it('floats a label match above a description-only match', () => { + renderPalette({ + canAttach: false, + entries: [ + entry({ key: 'a', label: 'Notes', description: 'nothing to do with drafting' }), + entry({ key: 'b', label: 'Drafting', description: 'unrelated' }), + ], + }); + search('draft'); + expect(keys()).toEqual(['h:tool', 'b', 'a']); + }); + + it('says so when nothing matches', () => { + renderPalette(); + search('nothing matches this'); + expect(screen.getByText('com_ui_composer_no_results')).toBeInTheDocument(); + }); + }); + + describe('keyboard', () => { + it('starts on the first row that can be chosen, never on a header', () => { + renderPalette(); + const input = screen.getByTestId('composer-palette-search'); + expect(input.getAttribute('aria-activedescendant')).toBe('palette-row-1'); + }); + + it('steps over headers on the way down', () => { + renderPalette(); + const input = screen.getByTestId('composer-palette-search'); + const active = () => + document.querySelector('[aria-selected="true"]')?.textContent?.trim() ?? ''; + + expect(active()).toBe('Upload to Provider'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(active()).toBe('com_ui_composer_attach_more'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + /* The tools header sits between them and is skipped. */ + expect(active()).toBe('Web Search'); + }); + + it('wraps from the last row back to the first', () => { + renderPalette({ canAttach: false, entries: [entry({ key: 'only', label: 'Only Tool' })] }); + const input = screen.getByTestId('composer-palette-search'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(document.querySelector('[aria-selected="true"]')?.textContent?.trim()).toBe( + 'Only Tool', + ); + }); + + it('chooses the active row on Enter', () => { + const onSelect = jest.fn(); + renderPalette({ + canAttach: false, + entries: [entry({ key: 'only', label: 'Only Tool', onSelect })], + }); + const input = screen.getByTestId('composer-palette-search'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it('stars the active row on Mod+D', () => { + renderPalette({ canAttach: false, entries: [entry({ key: 'only', label: 'Only Tool' })] }); + const input = screen.getByTestId('composer-palette-search'); + fireEvent.keyDown(input, { key: 'd', metaKey: true }); + expect(mockToggleFavorite).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/client/src/hooks/Input/__tests__/useAttachItems.spec.tsx b/client/src/hooks/Input/__tests__/useAttachItems.spec.tsx new file mode 100644 index 0000000000..2c2189cc14 --- /dev/null +++ b/client/src/hooks/Input/__tests__/useAttachItems.spec.tsx @@ -0,0 +1,286 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { renderHook } from '@testing-library/react'; +import { Tools, EModelEndpoint } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; +import useAttachItems from '../useAttachItems'; + +/** + * Ports the destination-visibility cases from the deleted `AttachFileMenu` + * spec, which is where this logic used to live. They cover the rule that + * decides which upload destinations exist at all — the one that silently lost + * both tool destinations when the palette stopped passing the ephemeral agent. + */ + +jest.mock('~/hooks', () => ({ + useAgentToolPermissions: jest.fn(), + useAgentCapabilities: jest.fn(), + useGetAgentsConfig: jest.fn(), +})); + +jest.mock('~/hooks/useLocalize', () => ({ + __esModule: true, + default: () => (key: string) => key, +})); + +jest.mock('~/hooks/Files', () => ({ + useFileHandlingNoChatContext: () => ({ handleFileChange: jest.fn() }), +})); + +jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({ + __esModule: true, + default: jest.fn(), + useSharePointFileHandlingNoChatContext: () => ({ + handleSharePointFiles: jest.fn(), + isProcessing: false, + downloadProgress: undefined, + }), +})); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: jest.fn(), +})); + +const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions; +const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities; +const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig; +const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig; + +const SAVED_AGENT = 'agent_abc123'; + +interface Options { + agentId?: string | null; + endpoint?: string | null; + endpointType?: EModelEndpoint | string; + useResponsesApi?: boolean; + provider?: string; + tools?: string[]; + contextEnabled?: boolean; + fileSearchEnabled?: boolean; + codeEnabled?: boolean; + sharePointEnabled?: boolean; +} + +/** Row ids, which are what the palette keys and folds on. */ +function renderEntries(options: Options = {}): string[] { + const { + agentId = null, + endpoint = null, + endpointType, + useResponsesApi, + provider, + tools, + contextEnabled = false, + fileSearchEnabled = false, + codeEnabled = false, + sharePointEnabled = false, + } = options; + + mockUseAgentToolPermissions.mockReturnValue({ tools, provider }); + mockUseAgentCapabilities.mockReturnValue({ contextEnabled, fileSearchEnabled, codeEnabled }); + mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: { capabilities: [] } }); + mockUseGetStartupConfig.mockReturnValue({ + data: { sharePointFilePickerEnabled: sharePointEnabled }, + }); + + const { result } = renderHook( + () => + useAttachItems({ + agentId, + endpoint, + endpointType, + useResponsesApi, + conversationId: 'convo-1', + conversation: { conversationId: 'convo-1' } as TConversation, + files: new Map(), + setFiles: jest.fn(), + setFilesLoading: jest.fn(), + }), + { + wrapper: ({ children }: { children: React.ReactNode }) => {children}, + }, + ); + + return result.current.entries.map((entry) => entry.id); +} + +const allCapabilities = { contextEnabled: true, fileSearchEnabled: true, codeEnabled: true }; + +describe('useAttachItems', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('provider upload versus image-only upload', () => { + it('offers a provider upload for a custom endpoint type', () => { + expect(renderEntries({ endpointType: 'custom' })).toContain('local:provider'); + }); + + it.each([EModelEndpoint.openAI, EModelEndpoint.anthropic, EModelEndpoint.google])( + 'offers a provider upload for %s', + (endpointType) => { + expect(renderEntries({ endpointType })).toContain('local:provider'); + }, + ); + + it('falls back to an image upload for the agents endpoint with no resolved provider', () => { + const ids = renderEntries({ endpointType: EModelEndpoint.agents }); + expect(ids).toContain('local:image'); + expect(ids).not.toContain('local:provider'); + }); + + it('resolves the provider through a saved agent, not the agents endpoint', () => { + expect( + renderEntries({ + agentId: SAVED_AGENT, + endpointType: EModelEndpoint.agents, + provider: EModelEndpoint.anthropic, + }), + ).toContain('local:provider'); + }); + + it('offers a provider upload for azureOpenAI only with the responses API', () => { + expect( + renderEntries({ provider: EModelEndpoint.azureOpenAI, useResponsesApi: true }), + ).toContain('local:provider'); + expect( + renderEntries({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: true }), + ).toContain('local:provider'); + expect( + renderEntries({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: false }), + ).toContain('local:image'); + }); + + it('reads a provider whatever its casing, which OpenRouter arrives in', () => { + expect(renderEntries({ provider: 'OpenRouter' })).toContain('local:provider'); + }); + }); + + describe('destinations behind an agent capability', () => { + it('offers text extraction when the context capability is on', () => { + expect(renderEntries({ contextEnabled: true })).toContain('local:context'); + expect(renderEntries({ contextEnabled: false })).not.toContain('local:context'); + }); + + it('offers a tool destination only when its capability is on', () => { + const ids = renderEntries({ fileSearchEnabled: true }); + expect(ids).toContain('local:file_search'); + expect(ids).not.toContain('local:execute_code'); + }); + + it('offers every destination when every capability is on', () => { + expect(renderEntries(allCapabilities)).toEqual([ + 'local:image', + 'local:context', + 'local:file_search', + 'local:execute_code', + ]); + }); + }); + + describe('which agent is allowed the tool destinations', () => { + /* The regression: an ordinary chat runs on an ephemeral agent, which has no + tool list. Reading one anyway hid both destinations, leaving drag and + drop as the only way to reach the code environment. */ + it('offers both tool destinations in an ordinary chat', () => { + const ids = renderEntries({ ...allCapabilities, agentId: null, tools: undefined }); + expect(ids).toContain('local:file_search'); + expect(ids).toContain('local:execute_code'); + }); + + it('offers both to an ephemeral agent, which carries no tool list', () => { + const ids = renderEntries({ + ...allCapabilities, + agentId: 'openAI__gpt-5___GPT-5', + tools: undefined, + }); + expect(ids).toContain('local:file_search'); + expect(ids).toContain('local:execute_code'); + }); + + it('offers a saved agent only the destinations it was built with', () => { + const ids = renderEntries({ + ...allCapabilities, + agentId: SAVED_AGENT, + tools: [Tools.file_search], + }); + expect(ids).toContain('local:file_search'); + expect(ids).not.toContain('local:execute_code'); + }); + + it('offers a saved agent with no tools neither destination', () => { + const ids = renderEntries({ ...allCapabilities, agentId: SAVED_AGENT, tools: [] }); + expect(ids).not.toContain('local:file_search'); + expect(ids).not.toContain('local:execute_code'); + expect(ids).toContain('local:context'); + }); + }); + + describe('SharePoint', () => { + it('mirrors every local destination when it is enabled', () => { + expect(renderEntries({ ...allCapabilities, sharePointEnabled: true })).toEqual([ + 'local:image', + 'local:context', + 'local:file_search', + 'local:execute_code', + 'sharepoint:image', + 'sharepoint:context', + 'sharepoint:file_search', + 'sharepoint:execute_code', + ]); + }); + + it('adds nothing when it is disabled', () => { + expect(renderEntries({ ...allCapabilities, sharePointEnabled: false })).toHaveLength(4); + }); + }); + + describe('what the palette folds away', () => { + /* The disclosure keeps one row out and hides the rest, so exactly one row + may claim to be the ordinary destination. */ + it('marks the provider row as the only primary destination', () => { + mockUseAgentToolPermissions.mockReturnValue({ tools: undefined, provider: undefined }); + mockUseAgentCapabilities.mockReturnValue(allCapabilities); + mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: { capabilities: [] } }); + mockUseGetStartupConfig.mockReturnValue({ + data: { sharePointFilePickerEnabled: true }, + }); + + const { result } = renderHook( + () => + useAttachItems({ + agentId: null, + endpoint: EModelEndpoint.anthropic, + endpointType: EModelEndpoint.anthropic, + conversationId: 'convo-1', + conversation: { conversationId: 'convo-1' } as TConversation, + files: new Map(), + setFiles: jest.fn(), + setFilesLoading: jest.fn(), + }), + { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }, + ); + + const primary = result.current.entries.filter((entry) => entry.primary === true); + expect(primary.map((entry) => entry.id)).toEqual(['local:provider']); + /* Including the SharePoint copy of the provider row: it is a second way + to reach a destination that is already out. */ + expect( + result.current.entries.find((entry) => entry.id === 'sharepoint:provider')?.primary, + ).toBe(false); + }); + }); + + describe('edge cases', () => { + it.each([ + ['undefined endpoint and provider', { endpoint: undefined, provider: undefined }], + ['null endpoint', { endpoint: null }], + ['missing agent id', { agentId: undefined }], + ['empty agent id', { agentId: '' }], + ])('still resolves a destination with %s', (_label, options) => { + expect(renderEntries(options as Options).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/client/src/hooks/Input/__tests__/useAttachTarget.spec.ts b/client/src/hooks/Input/__tests__/useAttachTarget.spec.ts new file mode 100644 index 0000000000..7a92e18840 --- /dev/null +++ b/client/src/hooks/Input/__tests__/useAttachTarget.spec.ts @@ -0,0 +1,201 @@ +import { renderHook } from '@testing-library/react'; +import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider'; +import type { TConversation, TEndpointsConfig } from 'librechat-data-provider'; +import useAttachTarget from '../useAttachTarget'; + +/** + * Ports the resolution cases from the deleted `AttachFileChat` spec, which is + * where this logic lived before the palette needed it. What an agent uploads + * to is decided here, and every branch of it is a fallback that fires only when + * the layer above it is missing. + */ + +const mockEndpointsConfig: TEndpointsConfig = { + [EModelEndpoint.openAI]: { userProvide: false, order: 0 }, + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +const defaultFileConfig = mergeFileConfig({ + endpoints: { + Moonshot: { fileLimit: 5 }, + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, +}); + +let mockFileConfig = defaultFileConfig; +/** Only the two fields the resolution reads, so a fixture stays a fixture. */ +interface AgentFixture { + provider?: string; + model_parameters?: { useResponsesApi?: boolean }; +} + +let mockAgentsMap: Record = {}; +let mockFetchedAgent: AgentFixture | undefined; +let mockAgentQueryEnabled: boolean | undefined; + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({ + data: select != null ? select(mockFileConfig) : mockFileConfig, + }), + useGetAgentByIdQuery: (_id: string | undefined, options?: { enabled?: boolean }) => { + mockAgentQueryEnabled = options?.enabled; + return { data: options?.enabled === false ? undefined : mockFetchedAgent }; + }, +})); + +jest.mock('~/Providers', () => ({ + useAgentsMapContext: () => mockAgentsMap, +})); + +type ConversationFixture = Omit, 'endpoint'> & { endpoint?: string }; + +const target = (conversation: ConversationFixture | null, disableInputs = false) => + renderHook(() => useAttachTarget(conversation as TConversation | null, disableInputs)).result + .current; + +describe('useAttachTarget', () => { + beforeEach(() => { + mockFileConfig = defaultFileConfig; + mockAgentsMap = {}; + mockFetchedAgent = undefined; + mockAgentQueryEnabled = undefined; + }); + + describe('endpoint type behind an agent', () => { + it('resolves a custom provider to its endpoint type', () => { + mockAgentsMap = { 'agent-1': { provider: 'Moonshot', model_parameters: {} } }; + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).endpointType).toBe( + EModelEndpoint.custom, + ); + }); + + it('resolves a first-party provider to itself', () => { + mockAgentsMap = { 'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } }; + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).endpointType).toBe( + EModelEndpoint.openAI, + ); + }); + + it('stays on the agents endpoint when the agent names no provider', () => { + mockAgentsMap = { 'agent-1': { model_parameters: {} } }; + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).endpointType).toBe( + EModelEndpoint.agents, + ); + }); + + it('fetches the agent only when the map has no parameters for it', () => { + mockAgentsMap = { 'agent-1': { provider: 'Moonshot', model_parameters: {} } }; + target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + expect(mockAgentQueryEnabled).toBe(false); + + mockAgentsMap = {}; + mockFetchedAgent = { provider: 'Moonshot' }; + const resolved = target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + expect(mockAgentQueryEnabled).toBe(true); + expect(resolved.endpointType).toBe(EModelEndpoint.custom); + }); + + it('falls back to the map when the fetched agent omits its provider', () => { + mockAgentsMap = { 'agent-1': { provider: 'Moonshot' } }; + mockFetchedAgent = { model_parameters: {} }; + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).endpointType).toBe( + EModelEndpoint.custom, + ); + }); + }); + + describe('the responses API flag', () => { + it('reads it from the fetched agent', () => { + mockFetchedAgent = { model_parameters: { useResponsesApi: true } }; + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).useResponsesApi).toBe( + true, + ); + }); + + it('falls back to the map when the fetched agent omits it', () => { + mockAgentsMap = { 'agent-1': { model_parameters: { useResponsesApi: true } } }; + mockFetchedAgent = { provider: EModelEndpoint.openAI }; + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).useResponsesApi).toBe( + true, + ); + }); + + /* An explicit false on the conversation is a decision, not an absence, so + neither fallback may overwrite it. */ + it('preserves an explicit false on the conversation', () => { + mockAgentsMap = { 'agent-1': { model_parameters: { useResponsesApi: true } } }; + mockFetchedAgent = { model_parameters: { useResponsesApi: true } }; + expect( + target({ + endpoint: EModelEndpoint.agents, + agent_id: 'agent-1', + useResponsesApi: false, + }).useResponsesApi, + ).toBe(false); + }); + + it('passes the conversation flag straight through outside the agents endpoint', () => { + expect( + target({ endpoint: EModelEndpoint.openAI, useResponsesApi: true }).useResponsesApi, + ).toBe(true); + }); + }); + + describe('endpoint type without an agent', () => { + it('resolves a custom endpoint', () => { + expect(target({ endpoint: 'Moonshot' }).endpointType).toBe(EModelEndpoint.custom); + }); + + it('leaves a first-party endpoint alone', () => { + expect(target({ endpoint: EModelEndpoint.openAI }).endpointType).toBe(EModelEndpoint.openAI); + }); + + /* Same endpoint, reached directly or through an agent: the destination has + to be the same or a file would upload differently depending on the route. */ + it('agrees with the agent route for the same custom endpoint', () => { + mockAgentsMap = { 'agent-1': { provider: 'Moonshot', model_parameters: {} } }; + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).endpointType).toBe( + target({ endpoint: 'Moonshot' }).endpointType, + ); + }); + }); + + describe('the file config the destination is scoped by', () => { + it('reads the config of the provider behind the agent, not of the agents endpoint', () => { + mockAgentsMap = { 'agent-1': { provider: 'Moonshot', model_parameters: {} } }; + expect( + target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).endpointFileConfig + ?.fileLimit, + ).toBe(5); + expect(target({ endpoint: EModelEndpoint.agents }).endpointFileConfig?.fileLimit).toBe(20); + }); + }); + + describe('whether uploads are offered at all', () => { + it('offers them on the agents endpoint', () => { + expect(target({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }).canAttach).toBe(true); + }); + + it('offers them on an endpoint that takes files', () => { + expect(target({ endpoint: EModelEndpoint.openAI }).canAttach).toBe(true); + }); + + it('withholds them while the composer is disabled', () => { + expect(target({ endpoint: EModelEndpoint.openAI }, true).canAttach).toBe(false); + }); + + it('withholds them when the endpoint config disables uploads', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { [EModelEndpoint.openAI]: { disabled: true }, default: { fileLimit: 10 } }, + }); + expect(target({ endpoint: EModelEndpoint.openAI }).canAttach).toBe(false); + }); + + it('withholds them with no conversation at all', () => { + expect(target(null).canAttach).toBe(false); + }); + }); +}); diff --git a/client/src/hooks/Input/__tests__/useChipPacking.spec.tsx b/client/src/hooks/Input/__tests__/useChipPacking.spec.tsx new file mode 100644 index 0000000000..a03879111d --- /dev/null +++ b/client/src/hooks/Input/__tests__/useChipPacking.spec.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import useChipPacking from '../useChipPacking'; + +/** + * The chips are reordered from their measured widths, so the interesting cases + * are the ones before a measurement exists and the ones after it goes stale. + */ + +/** jsdom lays nothing out, so each chip reports the width it was given. */ +const widthByLabel: Record = {}; +Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get(this: HTMLElement) { + return widthByLabel[this.textContent ?? ''] ?? 0; + }, +}); + +interface Chip { + key: string; +} + +let seen: string[] = []; +let measured: Record = {}; + +function Harness({ items }: { items: Chip[] }) { + const { ordered, rootRef, widths } = useChipPacking(items); + seen = ordered.map((item) => item.key); + measured = widths; + return ( +
+ {ordered.map((item) => ( + + {item.key} + + ))} +
+ ); +} + +const chips = (...keys: string[]): Chip[] => keys.map((key) => ({ key })); + +describe('useChipPacking', () => { + beforeEach(() => { + for (const key of Object.keys(widthByLabel)) { + delete widthByLabel[key]; + } + seen = []; + }); + + it('leaves the order alone while nothing can be measured', () => { + render(); + expect(seen).toEqual(['a', 'b', 'c']); + }); + + it('puts the widest chip first once every chip has been measured', () => { + Object.assign(widthByLabel, { a: 40, b: 120, c: 80 }); + render(); + expect(seen).toEqual(['b', 'c', 'a']); + }); + + /* A partial sort would shuffle on each pass and never settle, so a chip that + has not been measured holds the whole order back. */ + it('waits for the last chip rather than sorting what it has', () => { + Object.assign(widthByLabel, { a: 40, b: 120 }); + render(); + expect(seen).toEqual(['a', 'b', 'c']); + }); + + it('settles instead of oscillating', () => { + Object.assign(widthByLabel, { a: 40, b: 120, c: 80 }); + const { rerender } = render(); + const settled = seen; + rerender(); + expect(seen).toEqual(settled); + }); + + it('reorders when a chip joins', () => { + Object.assign(widthByLabel, { a: 40, b: 120, c: 80, d: 200 }); + const items = chips('a', 'b', 'c'); + const { rerender } = render(); + expect(seen).toEqual(['b', 'c', 'a']); + + act(() => { + rerender(); + }); + expect(seen).toEqual(['d', 'b', 'c', 'a']); + }); + + /* Widths are keyed by chip id and would otherwise outlive the chip, growing + with every chip the composer has ever shown. */ + it('forgets the width of a chip that left', () => { + Object.assign(widthByLabel, { a: 40, b: 120, c: 80 }); + const { rerender } = render(); + expect(Object.keys(measured).sort()).toEqual(['a', 'b', 'c']); + + act(() => { + rerender(); + }); + expect(Object.keys(measured).sort()).toEqual(['a', 'c']); + }); + + it('re-measures a chip that left and came back narrower', () => { + Object.assign(widthByLabel, { a: 40, b: 120, c: 80 }); + const { rerender } = render(); + expect(seen).toEqual(['b', 'c', 'a']); + + act(() => { + rerender(); + }); + + widthByLabel.b = 10; + act(() => { + rerender(); + }); + expect(seen).toEqual(['c', 'a', 'b']); + }); + + it('handles an empty list', () => { + render(); + expect(seen).toEqual([]); + }); +}); diff --git a/client/src/hooks/Input/__tests__/usePaletteEntries.spec.tsx b/client/src/hooks/Input/__tests__/usePaletteEntries.spec.tsx new file mode 100644 index 0000000000..78296d00e1 --- /dev/null +++ b/client/src/hooks/Input/__tests__/usePaletteEntries.spec.tsx @@ -0,0 +1,300 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { renderHook, act } from '@testing-library/react'; +import { ArtifactModes, PermissionTypes } from 'librechat-data-provider'; +import usePaletteEntries from '../usePaletteEntries'; +import store from '~/store'; + +/** + * What the palette is offered, before it decides how to draw it. Every row here + * is gated twice — by a permission and by an agent capability — and a row that + * appears without both is a tool the user cannot actually use. + */ + +let mockPermissions: Record; +let mockMemoryAccess: boolean; +let mockCapabilities: Record; +let mockContext: Record | null; +let mockSkills: Array>; +let mockAgentsMap: Record>; + +jest.mock('~/hooks', () => ({ + useHasAccess: ({ permissionType }: { permissionType: string }) => + mockPermissions[permissionType] ?? false, + useHasMemoryAccess: () => mockMemoryAccess, + useAgentCapabilities: () => mockCapabilities, + useSkillActiveState: () => ({ isActive: () => false }), +})); + +jest.mock('~/hooks/useLocalize', () => ({ + __esModule: true, + default: () => (key: string) => key, +})); + +jest.mock('~/Providers', () => ({ + useBadgeRowContext: () => mockContext, + useAgentsMapContext: () => mockAgentsMap, +})); + +jest.mock('~/data-provider', () => ({ + useSkillsInfiniteQuery: () => ({ + data: { pages: [{ skills: mockSkills }] }, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: jest.fn(), + }), +})); + +jest.mock('~/components/Chat/Input/SkillsCommand', () => ({ + filterSkillsForPopover: (skills: Array>) => skills, +})); + +const toggle = (state: unknown) => ({ toggleState: state, debouncedChange: jest.fn() }); + +interface ServerFixture { + serverName: string; + config?: { title?: string; description?: string; iconPath?: string }; +} + +interface ContextFixture { + mcpServerManager: { + selectableServers?: ServerFixture[]; + mcpValues: string[]; + toggleServerSelection: jest.Mock; + }; + [key: string]: unknown; +} + +const fullContext = (): ContextFixture => ({ + webSearch: toggle(false), + codeInterpreter: toggle(false), + fileSearch: toggle(false), + skills: toggle(false), + memory: toggle(false), + artifacts: toggle(''), + mcpServerManager: { + selectableServers: [ + { serverName: 'github', config: { title: 'Github', description: 'Repos' } }, + { serverName: 'spotify', config: {} }, + ], + mcpValues: [], + toggleServerSelection: jest.fn(), + }, + agentsConfig: { capabilities: [] }, +}); + +const allPermissions = { + [PermissionTypes.WEB_SEARCH]: true, + [PermissionTypes.RUN_CODE]: true, + [PermissionTypes.FILE_SEARCH]: true, + [PermissionTypes.MCP_SERVERS]: true, + [PermissionTypes.SKILLS]: true, +}; + +const allCapabilities = { + codeEnabled: true, + memoryEnabled: true, + webSearchEnabled: true, + artifactsEnabled: true, + fileSearchEnabled: true, + skillsEnabled: true, +}; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const entries = (agentId?: string | null) => + renderHook(() => usePaletteEntries({ conversationId: 'convo-1', agentId }), { wrapper }); + +const keysOf = (result: { current: Array<{ key: string }> }) => + result.current.map((item) => item.key); + +describe('usePaletteEntries', () => { + beforeEach(() => { + mockPermissions = { ...allPermissions }; + mockMemoryAccess = true; + mockCapabilities = { ...allCapabilities }; + mockContext = fullContext(); + mockSkills = []; + mockAgentsMap = {}; + }); + + it('offers nothing before the badge row has a context to read', () => { + mockContext = null; + expect(keysOf(entries().result)).toEqual([]); + }); + + it('lists the built-in tools in a fixed order, servers last', () => { + expect(keysOf(entries().result)).toEqual([ + 'builtin:web_search', + 'builtin:execute_code', + 'builtin:file_search', + 'builtin:skills', + 'builtin:memory', + 'builtin:artifacts', + 'mcp:github', + 'mcp:spotify', + ]); + }); + + describe('the two gates on every tool', () => { + it.each([ + [PermissionTypes.WEB_SEARCH, 'builtin:web_search'], + [PermissionTypes.RUN_CODE, 'builtin:execute_code'], + [PermissionTypes.FILE_SEARCH, 'builtin:file_search'], + [PermissionTypes.SKILLS, 'builtin:skills'], + [PermissionTypes.MCP_SERVERS, 'mcp:github'], + ])('withholds %s without the permission', (permission, key) => { + mockPermissions = { ...allPermissions, [permission]: false }; + expect(keysOf(entries().result)).not.toContain(key); + }); + + it.each([ + ['webSearchEnabled', 'builtin:web_search'], + ['codeEnabled', 'builtin:execute_code'], + ['fileSearchEnabled', 'builtin:file_search'], + ['skillsEnabled', 'builtin:skills'], + ['memoryEnabled', 'builtin:memory'], + ['artifactsEnabled', 'builtin:artifacts'], + ])('withholds %s without the capability', (capability, key) => { + mockCapabilities = { ...allCapabilities, [capability]: false }; + expect(keysOf(entries().result)).not.toContain(key); + }); + + it('withholds memory without access, whatever the capability says', () => { + mockMemoryAccess = false; + expect(keysOf(entries().result)).not.toContain('builtin:memory'); + }); + }); + + describe('on state', () => { + it('reads each tool from its own toggle', () => { + mockContext = { ...fullContext(), webSearch: toggle(true) }; + const listed = entries().result.current; + expect(listed.find((item) => item.key === 'builtin:web_search')?.active).toBe(true); + expect(listed.find((item) => item.key === 'builtin:execute_code')?.active).toBe(false); + }); + + it('marks a server on when it is among the selected values', () => { + const context = fullContext(); + context.mcpServerManager.mcpValues = ['spotify']; + mockContext = context; + const listed = entries().result.current; + expect(listed.find((item) => item.key === 'mcp:spotify')?.active).toBe(true); + expect(listed.find((item) => item.key === 'mcp:github')?.active).toBe(false); + }); + }); + + describe('artifacts, which carries its modes on the row', () => { + const artifactsRow = () => + entries().result.current.find((item) => item.key === 'builtin:artifacts'); + + it('offers no modes while it is off', () => { + mockContext = { ...fullContext(), artifacts: toggle('') }; + const row = artifactsRow(); + expect(row?.active).toBe(false); + expect(row?.modes).toBeUndefined(); + }); + + it('offers three modes once it is on', () => { + mockContext = { ...fullContext(), artifacts: toggle(ArtifactModes.DEFAULT) }; + const row = artifactsRow(); + expect(row?.active).toBe(true); + expect(row?.modes?.map((mode) => mode.id)).toEqual(['default', 'shadcn', 'custom']); + expect(row?.modes?.find((mode) => mode.id === 'default')?.active).toBe(true); + }); + + it('marks the stored mode, and only it', () => { + mockContext = { ...fullContext(), artifacts: toggle(ArtifactModes.SHADCNUI) }; + const modes = artifactsRow()?.modes; + expect(modes?.find((mode) => mode.id === 'shadcn')?.active).toBe(true); + expect(modes?.find((mode) => mode.id === 'default')?.active).toBe(false); + }); + + /* The toggle has historically also held a bare `true`, which names no mode + and would otherwise leave every one of them unchecked. */ + it('reads a bare true as the default mode', () => { + mockContext = { ...fullContext(), artifacts: toggle(true) }; + const row = artifactsRow(); + expect(row?.active).toBe(true); + expect(row?.modes?.find((mode) => mode.id === 'default')?.active).toBe(true); + }); + }); + + describe('skills', () => { + beforeEach(() => { + mockSkills = [ + { _id: 's1', name: 'writer', displayTitle: 'Writing Helper', description: 'Drafts' }, + { _id: 's2', name: 'writer', displayTitle: 'Writing Helper (copy)' }, + { _id: 's3', name: 'researcher' }, + ]; + }); + + /* Manual skills are primed by name server-side, so records sharing a name + are one selectable thing however many the catalog holds. */ + it('lists one row per name, not per record', () => { + expect(keysOf(entries().result).filter((key) => key.startsWith('skill:'))).toEqual([ + 'skill:s1', + 'skill:s3', + ]); + }); + + it('falls back to the name when a skill has no title', () => { + const listed = entries().result.current; + expect(listed.find((item) => item.key === 'skill:s3')?.label).toBe('researcher'); + expect(listed.find((item) => item.key === 'skill:s1')?.label).toBe('Writing Helper'); + }); + + it('marks a skill on once it is staged', () => { + const { result } = renderHook( + () => usePaletteEntries({ conversationId: 'convo-1', agentId: null }), + { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + set(store.pendingManualSkillsByConvoId('convo-1'), ['writer']) + } + > + {children} + + ), + }, + ); + expect(result.current.find((item) => item.key === 'skill:s1')?.active).toBe(true); + expect(result.current.find((item) => item.key === 'skill:s3')?.active).toBe(false); + }); + + it('stages and unstages a skill through the same row', () => { + const { result } = entries(null); + const row = () => result.current.find((item) => item.key === 'skill:s1'); + + act(() => row()?.onSelect()); + expect(row()?.active).toBe(true); + + act(() => row()?.onSelect()); + expect(row()?.active).toBe(false); + }); + + it('lists none of them when skills are not listable', () => { + mockCapabilities = { ...allCapabilities, skillsEnabled: false }; + expect(keysOf(entries().result).filter((key) => key.startsWith('skill:'))).toEqual([]); + }); + }); + + describe('servers', () => { + it('titles a server by its config, falling back to its name', () => { + const listed = entries().result.current; + expect(listed.find((item) => item.key === 'mcp:github')?.label).toBe('Github'); + expect(listed.find((item) => item.key === 'mcp:spotify')?.label).toBe('spotify'); + }); + + it('lists none while the manager has no selectable servers', () => { + const context = fullContext(); + context.mcpServerManager = { ...context.mcpServerManager, selectableServers: undefined }; + mockContext = context; + expect(keysOf(entries().result).filter((key) => key.startsWith('mcp:'))).toEqual([]); + }); + }); +}); diff --git a/client/src/utils/__tests__/getViableUploadOptions.spec.ts b/client/src/utils/__tests__/getViableUploadOptions.spec.ts index 5c1a7ff74e..e106c546d3 100644 --- a/client/src/utils/__tests__/getViableUploadOptions.spec.ts +++ b/client/src/utils/__tests__/getViableUploadOptions.spec.ts @@ -1,6 +1,10 @@ -import { EToolResources } from 'librechat-data-provider'; +import { Tools, EToolResources } from 'librechat-data-provider'; import type { FileConfig } from 'librechat-data-provider'; -import { getViableUploadOptions, type UploadOptionContext } from '../files'; +import { + getViableUploadOptions, + getUploadToolAllowances, + type UploadOptionContext, +} from '../files'; const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; @@ -145,3 +149,72 @@ describe('getViableUploadOptions', () => { ]); }); }); + +describe('getUploadToolAllowances', () => { + const SAVED = 'agent_abc123'; + + it('offers both tool destinations in an ordinary chat, where no agent decides', () => { + expect(getUploadToolAllowances(null, undefined)).toEqual({ + fileSearchAllowedByAgent: true, + codeAllowedByAgent: true, + }); + expect(getUploadToolAllowances(undefined, undefined)).toEqual({ + fileSearchAllowedByAgent: true, + codeAllowedByAgent: true, + }); + expect(getUploadToolAllowances('', undefined)).toEqual({ + fileSearchAllowedByAgent: true, + codeAllowedByAgent: true, + }); + }); + + /* The regression this rule was extracted for: an ephemeral agent carries no + tool list, and reading one left both destinations hidden for good. */ + it('offers both to an ephemeral agent, which has no tool list to consult', () => { + expect(getUploadToolAllowances('openAI__gpt-5___GPT-5', undefined)).toEqual({ + fileSearchAllowedByAgent: true, + codeAllowedByAgent: true, + }); + expect(getUploadToolAllowances('openAI__gpt-5___GPT-5', [])).toEqual({ + fileSearchAllowedByAgent: true, + codeAllowedByAgent: true, + }); + }); + + it('lets a saved agent offer only what it was built with', () => { + expect(getUploadToolAllowances(SAVED, [Tools.file_search])).toEqual({ + fileSearchAllowedByAgent: true, + codeAllowedByAgent: false, + }); + expect(getUploadToolAllowances(SAVED, [Tools.execute_code])).toEqual({ + fileSearchAllowedByAgent: false, + codeAllowedByAgent: true, + }); + expect(getUploadToolAllowances(SAVED, [Tools.file_search, Tools.execute_code])).toEqual({ + fileSearchAllowedByAgent: true, + codeAllowedByAgent: true, + }); + }); + + it('offers a saved agent nothing when its tools are absent or empty', () => { + expect(getUploadToolAllowances(SAVED, undefined)).toEqual({ + fileSearchAllowedByAgent: false, + codeAllowedByAgent: false, + }); + expect(getUploadToolAllowances(SAVED, [])).toEqual({ + fileSearchAllowedByAgent: false, + codeAllowedByAgent: false, + }); + expect(getUploadToolAllowances(SAVED, ['some_other_tool'])).toEqual({ + fileSearchAllowedByAgent: false, + codeAllowedByAgent: false, + }); + }); + + it('reads through an indexed agent id, which carries the suffix from a split view', () => { + expect(getUploadToolAllowances(`${SAVED}____1`, [Tools.execute_code])).toEqual({ + fileSearchAllowedByAgent: false, + codeAllowedByAgent: true, + }); + }); +});