From 0d8399729a4b84b9033d78460fc89237a77515e5 Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Wed, 5 Aug 2026 18:25:56 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=B8(front)=20cover=20the=20gap=20betwe?= =?UTF-8?q?en=20the=20summary=20and=20the=20first=20answer=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Laurent Paoletti --- .../chat/api/__tests__/useChat.test.tsx | 86 +++++++++++++ .../src/features/chat/api/useChat.tsx | 11 +- .../features/chat/components/MessageItem.tsx | 54 ++++++++ .../chat/components/SummarizationProgress.tsx | 16 ++- .../components/__tests__/MessageItem.test.tsx | 115 ++++++++++++++++-- .../__tests__/SummarizationProgress.test.tsx | 15 +++ 6 files changed, 286 insertions(+), 11 deletions(-) diff --git a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useChat.test.tsx b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useChat.test.tsx index 64a75c25..4cf91585 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useChat.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useChat.test.tsx @@ -1,10 +1,21 @@ +import { ReadableStream } from 'node:stream/web'; +import { TextDecoder, TextEncoder } from 'node:util'; +import { deserialize, serialize } from 'node:v8'; + import { Message } from '@ai-sdk/ui-utils'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { isImagesSkippedEvent, stampImagesSkippedOnLatestUserMessage, + useChat, } from '../useChat'; +jest.mock('@/api', () => ({ + fetchAPI: jest.fn(), +})); + describe('isImagesSkippedEvent', () => { it('accepts a chat_notice event', () => { expect( @@ -151,3 +162,78 @@ describe('stampImagesSkippedOnLatestUserMessage', () => { ).toEqual({ reason: 'model_text_only' }); }); }); + +// jsdom ships none of the globals the SDK uses to read a streamed response. +// v8 serialize/deserialize stands in for structuredClone (it keeps the Date on +// `createdAt`, which a JSON round-trip would flatten to a string). +Object.assign(globalThis, { + ReadableStream, + TextDecoder, + TextEncoder, + structuredClone: (value: T): T => deserialize(serialize(value)) as T, +}); + +const CHAT_API = 'chats/conv-1/conversation/'; + +// A turn cut short right after the `summarize` tool returned: the summary +// landed, the answer never started, and no `f:` (start_step) closes the +// message. That shape is what makes the SDK's multi-step continuation kick in. +const INTERRUPTED_AFTER_SUMMARY = [ + '9:{"toolCallId":"c1","toolName":"summarize","args":{"state":"running","summary_scope":"conversation"}}\n', + 'a:{"toolCallId":"c1","result":{"state":"done"}}\n', +].join(''); + +const streamOf = (payload: string) => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)); + controller.close(); + }, + }); + +describe('useChat multi-step continuation', () => { + const fetchAPIMock = jest.requireMock('@/api').fetchAPI as jest.Mock; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('does not re-POST the turn when a stream ends on a resolved tool call', async () => { + const chatCalls: string[] = []; + fetchAPIMock.mockImplementation((url: string) => { + if (url.startsWith('chat-cooldown')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cooldown_seconds: 0 }), + }); + } + chatCalls.push(url); + return Promise.resolve({ + ok: true, + body: streamOf(INTERRUPTED_AFTER_SUMMARY), + }); + }); + + const onError = jest.fn(); + const { result } = renderHook( + () => useChat({ id: 'conv-1', api: CHAT_API, onError }), + { wrapper }, + ); + + await act(async () => { + await result.current.append({ role: 'user', content: 'hello' }); + }); + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(onError).not.toHaveBeenCalled(); + + // A second POST would carry an assistant-terminated message list, which the + // backend answers with an empty stream — leaving the bubble blank. + expect(chatCalls).toEqual([CHAT_API]); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx index 136584f2..ce872013 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx @@ -157,7 +157,16 @@ export function useChat(options: Omit) { const result = useAiSdkChat({ ...restOptions, - maxSteps: 3, + // Single step: every tool loop runs server-side inside the agent, and we + // register no client-side tool (no `onToolCall`/`addToolResult`), so there + // is nothing for the client to continue. With maxSteps > 1 the SDK + // auto-resubmits whenever a stream ends with a resolved tool invocation and + // no trailing `start_step` — which is what an interrupted turn looks like + // once the `summarize` tool has returned. That resubmit flips the status + // away from `error`, hiding the failure, and the backend answers an + // assistant-terminated message list with an empty stream: the bubble stays + // blank instead of showing an error and a retry. + maxSteps: 1, fetch: fetchAPIAdapter, onFinish: (message, finishOptions) => { if (message.annotations?.length) { diff --git a/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx index 51894500..fe0e7ac3 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx @@ -304,6 +304,23 @@ const MessageItemComponent: React.FC = ({ chatErrorType === 'summarization_failed' && !!conversationSummarizeInvocation; + const [isSummarizationBarHidden, setIsSummarizationBarHidden] = + React.useState(false); + const handleSummarizationBarHidden = React.useCallback( + () => setIsSummarizationBarHidden(true), + [], + ); + + // Once the summary lands, the turn keeps streaming with an empty bubble until + // the model emits its first answer token. Without this the progress bar just + // vanishes and nothing replaces it, which reads as a stall. + const showPostSummarizationLoader = + isCurrentlyStreaming && + status === 'streaming' && + isSummarizationBarHidden && + !message.content && + !activeToolInvocation; + // Memoize the streaming content split to avoid recreating components in JSX const { completedBlocks, pending } = React.useMemo(() => { // When not streaming, everything is completed as a single block array @@ -434,9 +451,29 @@ const MessageItemComponent: React.FC = ({ > )} + {showPostSummarizationLoader && ( + + + + {t('Thinking...')} + + + )} {summarizationFailed && onRetry && ( = ({ MessageItemComponent.displayName = 'MessageItem'; +// Tool invocations go from `call` to `result` in place, without changing the +// parts count, so their states need their own signature: the summarization +// progress bar and the loader that replaces it hang on that transition. +const getToolInvocationStates = (message: Message): string => + (message.parts ?? []) + .filter( + (part): part is ToolInvocationUIPart => part.type === 'tool-invocation', + ) + .map((part) => part.toolInvocation.state) + .join(','); + // Custom comparison function for React.memo // Only re-render when props that affect rendering change const arePropsEqual = ( @@ -614,6 +662,12 @@ const arePropsEqual = ( if (prevPartsLength !== nextPartsLength) { return false; } + if ( + getToolInvocationStates(prevProps.message) !== + getToolInvocationStates(nextProps.message) + ) { + return false; + } // Check attachments const prevAttachmentsLength = diff --git a/src/frontend/apps/conversations/src/features/chat/components/SummarizationProgress.tsx b/src/frontend/apps/conversations/src/features/chat/components/SummarizationProgress.tsx index 43ad9c9f..44cc048a 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/SummarizationProgress.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/SummarizationProgress.tsx @@ -14,9 +14,16 @@ const HIDE_DELAY_MS = 400; interface SummarizationProgressProps { done: boolean; + /** Called once the bar has finished its completion animation and hidden + * itself, so the caller can take over the slot without overlapping it. + * Must be referentially stable (the hide timer restarts otherwise). */ + onHidden?: () => void; } -export const SummarizationProgress = ({ done }: SummarizationProgressProps) => { +export const SummarizationProgress = ({ + done, + onHidden, +}: SummarizationProgressProps) => { const { t } = useTranslation(); const [progress, setProgress] = React.useState(0); const [hidden, setHidden] = React.useState(false); @@ -38,9 +45,12 @@ export const SummarizationProgress = ({ done }: SummarizationProgressProps) => { return; } setProgress(1); - const timeout = setTimeout(() => setHidden(true), HIDE_DELAY_MS); + const timeout = setTimeout(() => { + setHidden(true); + onHidden?.(); + }, HIDE_DELAY_MS); return () => clearTimeout(timeout); - }, [done]); + }, [done, onHidden]); if (hidden) { return null; diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx index 18f33053..0dc5d65d 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx @@ -381,14 +381,16 @@ describe('MessageItem', () => { getMetadata: jest.fn(), }; + const withProviders = (ui: React.ReactNode) => ( + + + {ui} + + + ); + const renderWithProviders = (ui: React.ReactNode) => { - return render( - - - {ui} - - , - ); + return render(withProviders(ui)); }; beforeEach(() => { @@ -610,6 +612,10 @@ describe('MessageItem', () => { }); describe('summarization progress', () => { + afterEach(() => { + jest.useRealTimers(); + }); + const conversationSummarizeMessage = { id: 'msg-sum', role: 'assistant' as const, @@ -704,6 +710,101 @@ describe('MessageItem', () => { expect(onRetry).toHaveBeenCalledTimes(1); }); + it('takes over with a spinner once the progress bar has hidden itself', async () => { + jest.useFakeTimers(); + const summarizedMessage = { + ...conversationSummarizeMessage, + parts: [ + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'call-1', + toolName: 'summarize', + state: 'result' as const, + args: { state: 'running', summary_scope: 'conversation' }, + result: { state: 'done' }, + }, + }, + ], + }; + + const { rerender } = renderWithProviders( + , + ); + + // The summary result landed: the bar completes but is still on screen. + rerender( + withProviders( + , + ), + ); + expect(screen.getByTestId('summarization-progress')).toBeInTheDocument(); + expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); + + // Once it hides, the spinner covers the wait for the first answer token. + act(() => { + jest.advanceTimersByTime(400); + }); + expect( + screen.queryByTestId('summarization-progress'), + ).not.toBeInTheDocument(); + expect(screen.getByText('Thinking...')).toBeInTheDocument(); + }); + + it('drops the spinner once the answer starts streaming', async () => { + jest.useFakeTimers(); + const summarizedMessage = { + ...conversationSummarizeMessage, + parts: [ + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'call-1', + toolName: 'summarize', + state: 'result' as const, + args: { state: 'running', summary_scope: 'conversation' }, + result: { state: 'done' }, + }, + }, + ], + }; + + const { rerender } = renderWithProviders( + , + ); + act(() => { + jest.advanceTimersByTime(400); + }); + expect(screen.getByText('Thinking...')).toBeInTheDocument(); + + rerender( + withProviders( + , + ), + ); + expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); + }); + it('does not render the summarization error for other error types', async () => { await act(async () => { renderWithProviders( diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/SummarizationProgress.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/SummarizationProgress.test.tsx index b354a57e..68e56b66 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/SummarizationProgress.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/SummarizationProgress.test.tsx @@ -60,4 +60,19 @@ describe('SummarizationProgress', () => { screen.queryByTestId('summarization-progress'), ).not.toBeInTheDocument(); }); + + it('calls onHidden once the bar has hidden itself', () => { + const onHidden = jest.fn(); + const { rerender } = render( + , + ); + + rerender(); + expect(onHidden).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(400); + }); + expect(onHidden).toHaveBeenCalledTimes(1); + }); });