From d06d875d5cb956ff5d897c1949cd86e2c2fd36b1 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Tue, 7 Apr 2026 17:28:58 -0700 Subject: [PATCH] Queue up final response and show at the end. --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 97 +++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 42 +++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d6c68ec880..b9c596d8cf 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -66,6 +66,7 @@ import { MessageType, StreamingState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { theme } from '../semantic-colors.js'; +import { createMockSettings } from '../../test-utils/mockConfig.js'; // --- MOCKS --- const mockSendMessageStream = vi @@ -4240,4 +4241,100 @@ describe('useGeminiStream', () => { }); expect(spanMetadata.input).toBe('telemetry test query'); }); + + describe('topicUpdateNarration blocking', () => { + it('should block text updates while streaming and show them at the end if no tools are called', async () => { + const settings = createMockSettings({ + merged: { + experimental: { topicUpdateNarration: true }, + ui: { compactToolOutput: true }, + }, + }); + + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { type: ServerGeminiEventType.Content, value: 'Hello ' }; + yield { type: ServerGeminiEventType.Content, value: 'world!' }; + })(), + ); + + const { result } = await renderTestHook([], undefined, settings); + + await act(async () => { + await result.current.submitQuery('Hi'); + }); + + // During streaming, addItem should NOT have been called with 'gemini' type. + // However, it IS called at the end of the turn because there are no tool calls. + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'gemini', text: 'Hello world!' }), + expect.any(Number), + ); + }); + + it('should discard text updates if tool calls are present in the turn', async () => { + const settings = createMockSettings({ + merged: { + experimental: { topicUpdateNarration: true }, + ui: { compactToolOutput: true }, + }, + }); + + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: 'I will call a tool.', + }; + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { callId: '1', name: 'some_tool', args: {} }, + }; + })(), + ); + + const { result } = await renderTestHook([], undefined, settings); + + await act(async () => { + await result.current.submitQuery('Hi'); + }); + + // addItem should NOT have been called with 'gemini' type because there was a tool call. + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'gemini' }), + expect.any(Number), + ); + }); + + it('should block thinking history items when narration is enabled', async () => { + const settings = createMockSettings({ + merged: { + experimental: { topicUpdateNarration: true }, + ui: { inlineThinkingMode: 'full' }, + }, + }); + + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { thought: 'I am thinking...' }, + }; + yield { type: ServerGeminiEventType.Content, value: 'Final answer' }; + })(), + ); + + const { result } = await renderTestHook([], undefined, settings); + + await act(async () => { + await result.current.submitQuery('Hi'); + }); + + // addItem should NOT have been called with 'thinking' type. + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'thinking' }), + expect.any(Number), + ); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index a2621c4546..8ab1cb3caa 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -238,6 +238,8 @@ export const useGeminiStream = ( null, ); const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full'; + const topicUpdateNarrationEnabled = + settings.merged.experimental?.topicUpdateNarration === true; const suppressedToolErrorCountRef = useRef(0); const suppressedToolErrorNoteShownRef = useRef(false); const lowVerbosityFailureNoteShownRef = useRef(false); @@ -1082,9 +1084,24 @@ export const useGeminiStream = ( if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); } + + // When narration is enabled, we block all text updates during the turn. + // The text is accumulated and only shown at the end of the turn if + // no tool calls were made. + if (topicUpdateNarrationEnabled) { + setPendingHistoryItem(null); + return newGeminiMessageBuffer; + } + setPendingHistoryItem({ type: 'gemini', text: '' }); newGeminiMessageBuffer = eventValue; } + + // When narration is enabled, skip updating the UI with incremental text. + if (topicUpdateNarrationEnabled) { + return newGeminiMessageBuffer; + } + // Split large messages for better rendering performance. Ideally, // we should maximize the amount of output sent to . const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer); @@ -1123,21 +1140,31 @@ export const useGeminiStream = ( } return newGeminiMessageBuffer; }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem], + [ + addItem, + pendingHistoryItemRef, + setPendingHistoryItem, + topicUpdateNarrationEnabled, + ], ); const handleThoughtEvent = useCallback( (eventValue: ThoughtSummary, _userMessageTimestamp: number) => { setThought(eventValue); - if (getInlineThinkingMode(settings) === 'full') { + // Block thinking history items when narration is enabled to avoid + // UI flickering and provide a cleaner experience. + if ( + !topicUpdateNarrationEnabled && + getInlineThinkingMode(settings) === 'full' + ) { addItem({ type: 'thinking', thought: eventValue, } as HistoryItemThinking); } }, - [addItem, settings, setThought], + [addItem, settings, setThought, topicUpdateNarrationEnabled], ); const handleUserCancelledEvent = useCallback( @@ -1545,6 +1572,14 @@ export const useGeminiStream = ( setPendingHistoryItem(null); } await scheduleToolCalls(toolCallRequests, signal); + } else if ( + topicUpdateNarrationEnabled && + geminiMessageBuffer.length > 0 + ) { + // When narration is enabled, we only show the final text response + // if no tools were called in the current turn. This hides intermediate + // narration during multi-turn orchestration. + setPendingHistoryItem({ type: 'gemini', text: geminiMessageBuffer }); } return StreamProcessingStatus.Completed; }, @@ -1567,6 +1602,7 @@ export const useGeminiStream = ( pendingHistoryItemRef, setPendingHistoryItem, setThought, + topicUpdateNarrationEnabled, ], ); const submitQuery = useCallback(