mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-16 07:28:09 +01:00
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
|
|
import React from 'react';
|
||
|
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||
|
|
import { LocalStorageKeys, Tools } from 'librechat-data-provider';
|
||
|
|
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
|
||
|
|
import { ephemeralAgentByConvoId } from '~/store';
|
||
|
|
import { useToolToggle } from '../useToolToggle';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Tests for useToolToggle — the hook responsible for toggling tool badges
|
||
|
|
* (code execution, web search, file search, artifacts) and persisting state.
|
||
|
|
*
|
||
|
|
* Desired behaviors:
|
||
|
|
* - User toggles persist to per-conversation localStorage
|
||
|
|
* - In non-spec mode with specs configured (storageContextKey = '__defaults__'),
|
||
|
|
* toggles ALSO persist to the defaults key so future new conversations inherit them
|
||
|
|
* - In spec mode (storageContextKey = undefined), toggles only persist per-conversation
|
||
|
|
* - The hook reflects the current ephemeral agent state
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Mock data-provider auth query
|
||
|
|
jest.mock('~/data-provider', () => ({
|
||
|
|
useVerifyAgentToolAuth: jest.fn().mockReturnValue({
|
||
|
|
data: { authenticated: true },
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock timestamps (track calls without actual localStorage timestamp logic)
|
||
|
|
jest.mock('~/utils/timestamps', () => ({
|
||
|
|
setTimestamp: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock useLocalStorageAlt (isPinned state — not relevant to our behavior tests)
|
||
|
|
jest.mock('~/hooks/useLocalStorageAlt', () => jest.fn(() => [false, jest.fn()]));
|
||
|
|
|
||
|
|
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||
|
|
<RecoilRoot>{children}</RecoilRoot>
|
||
|
|
);
|
||
|
|
|
||
|
|
describe('useToolToggle', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
jest.clearAllMocks();
|
||
|
|
localStorage.clear();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Dual-Write Behavior ───────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('non-spec mode: dual-write to defaults key', () => {
|
||
|
|
const storageContextKey = '__defaults__';
|
||
|
|
|
||
|
|
it('should write to both conversation key and defaults key when user toggles a tool', () => {
|
||
|
|
const conversationId = 'convo-123';
|
||
|
|
const { result } = renderHook(
|
||
|
|
() =>
|
||
|
|
useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
storageContextKey,
|
||
|
|
toolKey: Tools.execute_code,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
}),
|
||
|
|
{ wrapper: Wrapper },
|
||
|
|
);
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
result.current.handleChange({ value: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Conversation key: per-conversation persistence
|
||
|
|
const convoKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${conversationId}`;
|
||
|
|
// Defaults key: persists for future new conversations
|
||
|
|
const defaultsKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${storageContextKey}`;
|
||
|
|
|
||
|
|
// Sync effect writes to conversation key
|
||
|
|
expect(localStorage.getItem(convoKey)).toBe(JSON.stringify(true));
|
||
|
|
// handleChange dual-writes to defaults key
|
||
|
|
expect(localStorage.getItem(defaultsKey)).toBe(JSON.stringify(true));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should persist false values to defaults key when user disables a tool', () => {
|
||
|
|
const { result } = renderHook(
|
||
|
|
() =>
|
||
|
|
useToolToggle({
|
||
|
|
conversationId: 'convo-456',
|
||
|
|
storageContextKey,
|
||
|
|
toolKey: Tools.web_search,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
}),
|
||
|
|
{ wrapper: Wrapper },
|
||
|
|
);
|
||
|
|
|
||
|
|
// Enable then disable
|
||
|
|
act(() => {
|
||
|
|
result.current.handleChange({ value: true });
|
||
|
|
});
|
||
|
|
act(() => {
|
||
|
|
result.current.handleChange({ value: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
const defaultsKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${storageContextKey}`;
|
||
|
|
expect(localStorage.getItem(defaultsKey)).toBe(JSON.stringify(false));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('spec mode: no dual-write', () => {
|
||
|
|
it('should only write to conversation key, not to any defaults key', () => {
|
||
|
|
const conversationId = 'spec-convo-789';
|
||
|
|
const { result } = renderHook(
|
||
|
|
() =>
|
||
|
|
useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
storageContextKey: undefined, // spec mode
|
||
|
|
toolKey: Tools.execute_code,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
}),
|
||
|
|
{ wrapper: Wrapper },
|
||
|
|
);
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
result.current.handleChange({ value: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Conversation key should have the value
|
||
|
|
const convoKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${conversationId}`;
|
||
|
|
expect(localStorage.getItem(convoKey)).toBe(JSON.stringify(true));
|
||
|
|
|
||
|
|
// Defaults key should NOT have a value
|
||
|
|
const defaultsKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}__defaults__`;
|
||
|
|
expect(localStorage.getItem(defaultsKey)).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Per-Conversation Isolation ────────────────────────────────────
|
||
|
|
|
||
|
|
describe('per-conversation isolation', () => {
|
||
|
|
it('should maintain separate toggle state per conversation', () => {
|
||
|
|
const TestComponent = ({ conversationId }: { conversationId: string }) => {
|
||
|
|
const toggle = useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
toolKey: Tools.execute_code,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
});
|
||
|
|
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId));
|
||
|
|
return { toggle, ephemeralAgent };
|
||
|
|
};
|
||
|
|
|
||
|
|
// Conversation A: enable code
|
||
|
|
const { result: resultA } = renderHook(() => TestComponent({ conversationId: 'convo-A' }), {
|
||
|
|
wrapper: Wrapper,
|
||
|
|
});
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
resultA.current.toggle.handleChange({ value: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Conversation B: disable code
|
||
|
|
const { result: resultB } = renderHook(() => TestComponent({ conversationId: 'convo-B' }), {
|
||
|
|
wrapper: Wrapper,
|
||
|
|
});
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
resultB.current.toggle.handleChange({ value: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Each conversation has its own value in localStorage
|
||
|
|
expect(localStorage.getItem(`${LocalStorageKeys.LAST_CODE_TOGGLE_}convo-A`)).toBe('true');
|
||
|
|
expect(localStorage.getItem(`${LocalStorageKeys.LAST_CODE_TOGGLE_}convo-B`)).toBe('false');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Ephemeral Agent Sync ──────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('ephemeral agent reflects toggle state', () => {
|
||
|
|
it('should update ephemeral agent when user toggles a tool', async () => {
|
||
|
|
const conversationId = 'convo-sync-test';
|
||
|
|
const TestComponent = () => {
|
||
|
|
const toggle = useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
toolKey: Tools.execute_code,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
});
|
||
|
|
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId));
|
||
|
|
return { toggle, ephemeralAgent };
|
||
|
|
};
|
||
|
|
|
||
|
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
result.current.toggle.handleChange({ value: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(result.current.ephemeralAgent?.execute_code).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
result.current.toggle.handleChange({ value: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(result.current.ephemeralAgent?.execute_code).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should reflect external ephemeral agent changes in toolValue', async () => {
|
||
|
|
const conversationId = 'convo-external';
|
||
|
|
const TestComponent = () => {
|
||
|
|
const toggle = useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
toolKey: Tools.web_search,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
});
|
||
|
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
|
||
|
|
return { toggle, setEphemeralAgent };
|
||
|
|
};
|
||
|
|
|
||
|
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||
|
|
|
||
|
|
// External update (e.g., from applyModelSpecEphemeralAgent)
|
||
|
|
act(() => {
|
||
|
|
result.current.setEphemeralAgent({ web_search: true, execute_code: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(result.current.toggle.toolValue).toBe(true);
|
||
|
|
expect(result.current.toggle.isToolEnabled).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should sync externally-set ephemeral agent values to localStorage', async () => {
|
||
|
|
const conversationId = 'convo-sync-ls';
|
||
|
|
const TestComponent = () => {
|
||
|
|
const toggle = useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
toolKey: Tools.file_search,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
});
|
||
|
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
|
||
|
|
return { toggle, setEphemeralAgent };
|
||
|
|
};
|
||
|
|
|
||
|
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||
|
|
|
||
|
|
// Simulate applyModelSpecEphemeralAgent setting a value
|
||
|
|
act(() => {
|
||
|
|
result.current.setEphemeralAgent({ file_search: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
// The sync effect should write to conversation-keyed localStorage
|
||
|
|
await waitFor(() => {
|
||
|
|
const storageKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${conversationId}`;
|
||
|
|
expect(localStorage.getItem(storageKey)).toBe(JSON.stringify(true));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── isToolEnabled computation ─────────────────────────────────────
|
||
|
|
|
||
|
|
describe('isToolEnabled computation', () => {
|
||
|
|
it('should return false when tool is not set', () => {
|
||
|
|
const { result } = renderHook(
|
||
|
|
() =>
|
||
|
|
useToolToggle({
|
||
|
|
conversationId: 'convo-1',
|
||
|
|
toolKey: Tools.execute_code,
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
}),
|
||
|
|
{ wrapper: Wrapper },
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(result.current.isToolEnabled).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should treat non-empty string as enabled (artifacts)', async () => {
|
||
|
|
const conversationId = 'convo-artifacts';
|
||
|
|
const TestComponent = () => {
|
||
|
|
const toggle = useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
toolKey: 'artifacts',
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
});
|
||
|
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
|
||
|
|
return { toggle, setEphemeralAgent };
|
||
|
|
};
|
||
|
|
|
||
|
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
result.current.setEphemeralAgent({ artifacts: 'default' });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(result.current.toggle.isToolEnabled).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should treat empty string as disabled (artifacts off)', async () => {
|
||
|
|
const conversationId = 'convo-no-artifacts';
|
||
|
|
const TestComponent = () => {
|
||
|
|
const toggle = useToolToggle({
|
||
|
|
conversationId,
|
||
|
|
toolKey: 'artifacts',
|
||
|
|
localStorageKey: LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
|
||
|
|
isAuthenticated: true,
|
||
|
|
});
|
||
|
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
|
||
|
|
return { toggle, setEphemeralAgent };
|
||
|
|
};
|
||
|
|
|
||
|
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
result.current.setEphemeralAgent({ artifacts: '' });
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(result.current.toggle.isToolEnabled).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|