From 9c4049b1c902124a35bdbda5c2716bdde9d4ffc6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 24 May 2026 08:39:31 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A1=20fix:=20Prevent=20Hover=20Actions?= =?UTF-8?q?=20Flash=20While=20Streaming=20(#13285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/useMemoizedChatContext.spec.tsx | 64 ++++++ .../useEventHandlers.latestMessage.spec.tsx | 215 ++++++++++++++++++ .../SSE/__tests__/useResumableSSE.spec.ts | 2 +- client/src/hooks/SSE/useAdaptiveSSE.ts | 2 +- client/src/hooks/SSE/useEventHandlers.ts | 37 ++- client/src/hooks/SSE/useResumableSSE.ts | 6 +- client/src/hooks/SSE/useSSE.ts | 6 +- 7 files changed, 303 insertions(+), 29 deletions(-) create mode 100644 client/src/hooks/Messages/__tests__/useMemoizedChatContext.spec.tsx create mode 100644 client/src/hooks/SSE/__tests__/useEventHandlers.latestMessage.spec.tsx diff --git a/client/src/hooks/Messages/__tests__/useMemoizedChatContext.spec.tsx b/client/src/hooks/Messages/__tests__/useMemoizedChatContext.spec.tsx new file mode 100644 index 0000000000..75b91929d5 --- /dev/null +++ b/client/src/hooks/Messages/__tests__/useMemoizedChatContext.spec.tsx @@ -0,0 +1,64 @@ +import { renderHook } from '@testing-library/react'; +import type { TConversation, TMessage } from 'librechat-data-provider'; +import { useChatContext } from '~/Providers'; +import useMemoizedChatContext from '../useMemoizedChatContext'; + +jest.mock('~/Providers', () => ({ + useChatContext: jest.fn(), +})); + +const mockUseChatContext = useChatContext as jest.MockedFunction; + +const conversation = { + conversationId: 'convo-id', + endpoint: 'openAI', + model: 'gpt-4', +} as TConversation; + +const message = (overrides: Partial = {}) => + ({ + messageId: 'assistant-response_', + parentMessageId: 'user-message', + conversationId: 'convo-id', + sender: 'Assistant', + text: '', + isCreatedByUser: false, + children: [], + ...overrides, + }) as TMessage; + +function mockChatContext(latestMessageId: string | undefined) { + mockUseChatContext.mockReturnValue({ + ask: jest.fn(), + index: 0, + regenerate: jest.fn(), + conversation, + latestMessageId, + latestMessageDepth: -1, + handleContinue: jest.fn(), + isSubmitting: true, + } as unknown as ReturnType); +} + +describe('useMemoizedChatContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('treats the latest message as submitting while streaming', () => { + mockChatContext('assistant-response_'); + + const { result } = renderHook(() => useMemoizedChatContext(message(), true)); + + expect(result.current.effectiveIsSubmitting).toBe(true); + }); + + it('requires latestMessageId alignment before marking a row as submitting', () => { + mockChatContext(undefined); + + const { result } = renderHook(() => useMemoizedChatContext(message(), true)); + + expect(result.current.effectiveIsSubmitting).toBe(false); + expect(result.current.chatContext.isSubmitting).toBe(true); + }); +}); diff --git a/client/src/hooks/SSE/__tests__/useEventHandlers.latestMessage.spec.tsx b/client/src/hooks/SSE/__tests__/useEventHandlers.latestMessage.spec.tsx new file mode 100644 index 0000000000..46d982428d --- /dev/null +++ b/client/src/hooks/SSE/__tests__/useEventHandlers.latestMessage.spec.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Constants } from 'librechat-data-provider'; +import type { TConversation, TMessage, EventSubmission } from 'librechat-data-provider'; +import useEventHandlers, { type EventHandlerParams } from '../useEventHandlers'; +import { renderHook, act } from '@testing-library/react'; + +const mockAnnouncePolite = jest.fn(); +const mockApplyAgentTemplate = jest.fn(); +const mockContentHandler = jest.fn(); +const mockResetContentHandler = jest.fn(); +const mockStepHandler = jest.fn(); +const mockClearStepMaps = jest.fn(); +const mockResetSubagentAtoms = jest.fn(); +const mockSyncStepMessage = jest.fn(); +const mockAttachmentHandler = jest.fn(); + +jest.mock('~/Providers', () => ({ + useLiveAnnouncer: () => ({ announcePolite: mockAnnouncePolite }), +})); + +jest.mock('~/hooks/AuthContext', () => ({ + useAuthContext: () => ({ token: 'token' }), +})); + +jest.mock('~/hooks/Agents', () => ({ + useApplyAgentTemplate: () => mockApplyAgentTemplate, +})); + +jest.mock('~/hooks/SSE/useContentHandler', () => () => ({ + contentHandler: mockContentHandler, + resetContentHandler: mockResetContentHandler, +})); + +jest.mock('~/hooks/SSE/useStepHandler', () => () => ({ + stepHandler: mockStepHandler, + clearStepMaps: mockClearStepMaps, + resetSubagentAtoms: mockResetSubagentAtoms, + syncStepMessage: mockSyncStepMessage, +})); + +jest.mock('~/hooks/SSE/useAttachmentHandler', () => () => mockAttachmentHandler); + +const conversation = { + conversationId: 'convo-id', + endpoint: 'openAI', + model: 'gpt-4', + title: 'New Chat', +} as TConversation; + +const userMessage = { + messageId: 'user-message', + parentMessageId: Constants.NO_PARENT, + conversationId: 'convo-id', + sender: 'User', + text: 'hello', + isCreatedByUser: true, +} as TMessage; + +const initialResponse = { + messageId: 'user-message_', + parentMessageId: 'user-message', + conversationId: 'convo-id', + sender: 'Assistant', + text: '', + isCreatedByUser: false, +} as TMessage; + +const submission = { + conversation, + messages: [] as TMessage[], + userMessage, + initialResponse, +} as unknown as EventSubmission; + +function wrapper({ children }: { children: React.ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return ( + + + {children} + + + ); +} + +const expectedCreatedAssistantResponse = () => + expect.objectContaining({ + messageId: 'user-message_', + parentMessageId: 'user-message', + isCreatedByUser: false, + }); + +type EventHandlerSpies = Omit< + EventHandlerParams, + | 'setMessages' + | 'setLatestMessage' + | 'getMessages' + | 'setCompleted' + | 'setIsSubmitting' + | 'setShowStopButton' +> & { + setMessages: jest.Mock; + setLatestMessage: jest.Mock; + getMessages: jest.Mock; + setCompleted: jest.Mock; + setIsSubmitting: jest.Mock; + setShowStopButton: jest.Mock; +}; + +function renderEventHandlersWithSpies(overrides: Partial = {}) { + const defaultSpies: EventHandlerSpies = { + setMessages: jest.fn(), + setLatestMessage: jest.fn(), + getMessages: jest.fn(() => []), + setCompleted: jest.fn(), + setIsSubmitting: jest.fn(), + setShowStopButton: jest.fn(), + }; + const spies: EventHandlerSpies = { ...defaultSpies, ...overrides }; + + const { result } = renderHook(() => useEventHandlers(spies), { wrapper }); + + return { result, ...spies }; +} + +describe('useEventHandlers latest-message transitions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps latestMessage aligned after replacing optimistic messages in createdHandler', () => { + const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies(); + + act(() => { + result.current.createdHandler( + { created: true } as unknown as Parameters[0], + submission, + ); + }); + + expect(setMessages).toHaveBeenCalledWith([userMessage, expectedCreatedAssistantResponse()]); + expect(setLatestMessage).toHaveBeenCalledWith(expectedCreatedAssistantResponse()); + expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan( + setMessages.mock.invocationCallOrder[0], + ); + }); + + it('keeps latestMessage aligned when createdHandler starts a regeneration', () => { + const previousMessage = { + ...userMessage, + messageId: 'previous-message', + } as TMessage; + const regenerateSubmission = { + ...submission, + messages: [previousMessage], + isRegenerate: true, + } as unknown as EventSubmission; + const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies({ + getMessages: jest.fn(() => [previousMessage]), + }); + + act(() => { + result.current.createdHandler( + { created: true } as unknown as Parameters[0], + regenerateSubmission, + ); + }); + + expect(setMessages).toHaveBeenCalledWith([previousMessage, expectedCreatedAssistantResponse()]); + expect(setLatestMessage).toHaveBeenCalledWith(expectedCreatedAssistantResponse()); + expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan( + setMessages.mock.invocationCallOrder[0], + ); + }); + + it('keeps latestMessage aligned after replacing optimistic messages in syncHandler', () => { + const requestMessage = { ...userMessage, messageId: 'server-user-message' } as TMessage; + const responseMessage = { + ...initialResponse, + messageId: 'server-assistant-response', + parentMessageId: 'server-user-message', + } as TMessage; + const setShowStopButton = jest.fn(); + const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies({ + getMessages: jest.fn(() => [userMessage, initialResponse]), + setShowStopButton, + }); + + act(() => { + result.current.syncHandler( + { + sync: true, + conversationId: 'convo-id', + thread_id: 'thread-id', + requestMessage, + responseMessage, + }, + submission, + ); + }); + + expect(setMessages).toHaveBeenCalledWith([requestMessage, responseMessage]); + expect(setShowStopButton).toHaveBeenCalledWith(true); + expect(setLatestMessage).toHaveBeenCalledWith(responseMessage); + expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan( + setMessages.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index 269f38c865..7d2942448f 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -156,7 +156,7 @@ const buildChatHelpers = () => ({ setConversation: jest.fn(), setIsSubmitting: mockSetIsSubmitting, newConversation: jest.fn(), - resetLatestMessage: jest.fn(), + setLatestMessage: jest.fn(), }); const getLastSSE = (): MockSSEInstance => { diff --git a/client/src/hooks/SSE/useAdaptiveSSE.ts b/client/src/hooks/SSE/useAdaptiveSSE.ts index e8c2de08e0..d3acf67e3c 100644 --- a/client/src/hooks/SSE/useAdaptiveSSE.ts +++ b/client/src/hooks/SSE/useAdaptiveSSE.ts @@ -11,7 +11,7 @@ type ChatHelpers = Pick< | 'setConversation' | 'setIsSubmitting' | 'newConversation' - | 'resetLatestMessage' + | 'setLatestMessage' >; /** diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index e55bd6261c..902cfeffcd 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -21,7 +21,7 @@ import type { } from 'librechat-data-provider'; import type { TResData, TFinalResData, ConvoGenerator } from '~/common'; import type { InfiniteData } from '@tanstack/react-query'; -import type { SetterOrUpdater, Resetter } from 'recoil'; +import type { SetterOrUpdater } from 'recoil'; import type { ConversationCursorData } from '~/utils'; import { logger, @@ -62,7 +62,7 @@ export type EventHandlerParams = { setConversation?: SetterOrUpdater; newConversation?: ConvoGenerator; setShowStopButton: SetterOrUpdater; - resetLatestMessage?: Resetter; + setLatestMessage: SetterOrUpdater; }; const createErrorMessage = ({ @@ -175,7 +175,7 @@ export default function useEventHandlers({ setIsSubmitting, newConversation, setShowStopButton, - resetLatestMessage, + setLatestMessage, }: EventHandlerParams) { const queryClient = useQueryClient(); const { announcePolite } = useLiveAnnouncer(); @@ -323,14 +323,14 @@ export default function useEventHandlers({ const { initialResponse, messages: _messages, userMessage } = submission; const messages = _messages.filter((msg) => msg.messageId !== userMessage.messageId); - setMessages([ - ...messages, - requestMessage, - { - ...initialResponse, - ...responseMessage, - }, - ]); + const nextResponseMessage = { + ...initialResponse, + ...responseMessage, + }; + + logger.log('latest_message', 'syncHandler: setting latest message'); + setLatestMessage(nextResponseMessage); + setMessages([...messages, requestMessage, nextResponseMessage]); announcePolite({ message: 'start', @@ -375,10 +375,6 @@ export default function useEventHandlers({ } setShowStopButton(true); - if (resetLatestMessage) { - logger.log('latest_message', 'syncHandler: resetting latest message'); - resetLatestMessage(); - } }, [ queryClient, @@ -387,7 +383,7 @@ export default function useEventHandlers({ announcePolite, setConversation, setShowStopButton, - resetLatestMessage, + setLatestMessage, ], ); @@ -410,7 +406,10 @@ export default function useEventHandlers({ ...submission.initialResponse, parentMessageId: userMessage.messageId, messageId: userMessage.messageId + '_', + conversationId: userMessage.conversationId ?? submission.initialResponse.conversationId, }; + logger.log('latest_message', 'createdHandler: setting latest message'); + setLatestMessage(initialResponse); if (isRegenerate) { setMessages([...messages, initialResponse]); } else { @@ -469,10 +468,6 @@ export default function useEventHandlers({ }); } - if (resetLatestMessage) { - logger.log('latest_message', 'createdHandler: resetting latest message'); - resetLatestMessage(); - } scrollToEnd(() => setAbortScroll(false)); }, [ @@ -482,7 +477,7 @@ export default function useEventHandlers({ isAddedRequest, announcePolite, setConversation, - resetLatestMessage, + setLatestMessage, applyAgentTemplate, ], ); diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index f0ee12ecc1..3729f4dbcc 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -35,7 +35,7 @@ type ChatHelpers = Pick< | 'setConversation' | 'setIsSubmitting' | 'newConversation' - | 'resetLatestMessage' + | 'setLatestMessage' >; const MAX_RETRIES = 5; @@ -102,7 +102,7 @@ export default function useResumableSSE( setConversation, setIsSubmitting, newConversation, - resetLatestMessage, + setLatestMessage, } = chatHelpers; const { @@ -125,7 +125,7 @@ export default function useResumableSSE( setIsSubmitting, newConversation, setShowStopButton, - resetLatestMessage, + setLatestMessage, }); const { data: startupConfig } = useGetStartupConfig(); diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index 78835f5729..13a2963d58 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -19,7 +19,7 @@ type ChatHelpers = Pick< | 'setConversation' | 'setIsSubmitting' | 'newConversation' - | 'resetLatestMessage' + | 'setLatestMessage' >; export default function useSSE( @@ -41,7 +41,7 @@ export default function useSSE( setConversation, setIsSubmitting, newConversation, - resetLatestMessage, + setLatestMessage, } = chatHelpers; const { @@ -64,7 +64,7 @@ export default function useSSE( setIsSubmitting, newConversation, setShowStopButton, - resetLatestMessage, + setLatestMessage, }); const { data: startupConfig } = useGetStartupConfig();