mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
test: cover the palette and the attach hooks
The redesign deleted the attach menu specs along with their components, but that logic moved into hooks and went untested, which is how the code environment upload regressed. Restores those cases and adds the palette row model, the entries it is offered, and chip packing.
This commit is contained in:
parent
440b097d99
commit
d45d3b3c30
7 changed files with 1302 additions and 3 deletions
|
|
@ -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<HTMLElement>('[data-row-key]')) {
|
||||
|
|
|
|||
|
|
@ -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<string, string | number>) =>
|
||||
options ? `${key}:${options['0'] ?? options.count}` : key,
|
||||
}));
|
||||
|
||||
let mockFavoriteKeys: string[] = [];
|
||||
let mockRecentFiles: Array<Record<string, unknown>> = [];
|
||||
let mockAttachEntries: AttachEntry[] = [];
|
||||
|
||||
const attachEntry = (id: string, label: string, primary = false): AttachEntry => ({
|
||||
id,
|
||||
label,
|
||||
primary,
|
||||
icon: null,
|
||||
onSelect: jest.fn(),
|
||||
});
|
||||
|
||||
const entry = (over: Partial<PaletteEntry> & Pick<PaletteEntry, 'key'>): 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(
|
||||
<RecoilRoot>
|
||||
<Palette
|
||||
index={0}
|
||||
conversationId="convo-1"
|
||||
conversation={{ conversationId: 'convo-1' } as TConversation}
|
||||
files={new Map()}
|
||||
setFiles={jest.fn()}
|
||||
setFilesLoading={jest.fn()}
|
||||
canAttach={over.canAttach ?? true}
|
||||
entries={over.entries ?? ENTRIES}
|
||||
anchorRef={anchorRef as React.RefObject<HTMLElement>}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
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<HTMLElement>('#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);
|
||||
});
|
||||
});
|
||||
});
|
||||
286
client/src/hooks/Input/__tests__/useAttachItems.spec.tsx
Normal file
286
client/src/hooks/Input/__tests__/useAttachItems.spec.tsx
Normal file
|
|
@ -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 }) => <RecoilRoot>{children}</RecoilRoot>,
|
||||
},
|
||||
);
|
||||
|
||||
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 }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
client/src/hooks/Input/__tests__/useAttachTarget.spec.ts
Normal file
201
client/src/hooks/Input/__tests__/useAttachTarget.spec.ts
Normal file
|
|
@ -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<string, AgentFixture> = {};
|
||||
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<Partial<TConversation>, '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);
|
||||
});
|
||||
});
|
||||
});
|
||||
123
client/src/hooks/Input/__tests__/useChipPacking.spec.tsx
Normal file
123
client/src/hooks/Input/__tests__/useChipPacking.spec.tsx
Normal file
|
|
@ -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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
|
||||
function Harness({ items }: { items: Chip[] }) {
|
||||
const { ordered, rootRef, widths } = useChipPacking(items);
|
||||
seen = ordered.map((item) => item.key);
|
||||
measured = widths;
|
||||
return (
|
||||
<div ref={rootRef}>
|
||||
{ordered.map((item) => (
|
||||
<span key={item.key} role="listitem">
|
||||
{item.key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(<Harness items={chips('a', 'b', 'c')} />);
|
||||
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(<Harness items={chips('a', 'b', 'c')} />);
|
||||
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(<Harness items={chips('a', 'b', 'c')} />);
|
||||
expect(seen).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('settles instead of oscillating', () => {
|
||||
Object.assign(widthByLabel, { a: 40, b: 120, c: 80 });
|
||||
const { rerender } = render(<Harness items={chips('a', 'b', 'c')} />);
|
||||
const settled = seen;
|
||||
rerender(<Harness items={chips('a', 'b', 'c')} />);
|
||||
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(<Harness items={items} />);
|
||||
expect(seen).toEqual(['b', 'c', 'a']);
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness items={chips('a', 'b', 'c', 'd')} />);
|
||||
});
|
||||
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(<Harness items={chips('a', 'b', 'c')} />);
|
||||
expect(Object.keys(measured).sort()).toEqual(['a', 'b', 'c']);
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness items={chips('a', 'c')} />);
|
||||
});
|
||||
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(<Harness items={chips('a', 'b', 'c')} />);
|
||||
expect(seen).toEqual(['b', 'c', 'a']);
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness items={chips('a', 'c')} />);
|
||||
});
|
||||
|
||||
widthByLabel.b = 10;
|
||||
act(() => {
|
||||
rerender(<Harness items={chips('a', 'b', 'c')} />);
|
||||
});
|
||||
expect(seen).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('handles an empty list', () => {
|
||||
render(<Harness items={[]} />);
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
300
client/src/hooks/Input/__tests__/usePaletteEntries.spec.tsx
Normal file
300
client/src/hooks/Input/__tests__/usePaletteEntries.spec.tsx
Normal file
|
|
@ -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<string, boolean>;
|
||||
let mockMemoryAccess: boolean;
|
||||
let mockCapabilities: Record<string, boolean>;
|
||||
let mockContext: Record<string, unknown> | null;
|
||||
let mockSkills: Array<Record<string, unknown>>;
|
||||
let mockAgentsMap: Record<string, Record<string, unknown>>;
|
||||
|
||||
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<Record<string, unknown>>) => 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 }) => (
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
);
|
||||
|
||||
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 }) => (
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) =>
|
||||
set(store.pendingManualSkillsByConvoId('convo-1'), ['writer'])
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</RecoilRoot>
|
||||
),
|
||||
},
|
||||
);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue