Queue up final response and show at the end.

This commit is contained in:
Christian Gunderman
2026-04-07 17:28:58 -07:00
parent 316ed83b79
commit d06d875d5c
2 changed files with 136 additions and 3 deletions
@@ -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),
);
});
});
});
+39 -3
View File
@@ -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 <Static />.
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(