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 }) => ( {children} ); 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); }); }); }); });