From 6763124a057c524ebf92dce5aa96761b63c824fc Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 28 Apr 2026 16:32:59 +0800 Subject: [PATCH] fix(cli): preserve description in subject-bearing thought chunks (#3691) When a streamed reasoning chunk arrived with both a parsed subject (from **Title**) and a description (the body text after \n\n in the same chunk), the Thought event handler routed only to setThought and discarded the description. As a result, the first body word that happened to share a chunk with the closing ** was dropped from the persistent reasoning display. Treat subject-only chunks as discrete loading-indicator updates and route all chunks carrying streamed text through the throttled buffer. The existing flush merger preserves the subject across batched events. --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 45 +++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 9 ++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 0616b97ed..fc2418889 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2734,6 +2734,51 @@ describe('useGeminiStream', () => { }); }); + it('should render descriptions from subject-bearing thought chunks', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { + subject: 'Evaluating installation approach', + description: 'The', + }, + }; + yield { + type: ServerGeminiEventType.Thought, + value: { + subject: '', + description: ' user mentioned globally installed qwen,', + }, + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('Streamed thought'); + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'gemini_thought', + text: 'The user mentioned globally installed qwen,', + }), + expect.any(Number), + ); + }); + expect(result.current.thought).toEqual({ + subject: 'Evaluating installation approach', + description: 'The user mentioned globally installed qwen,', + }); + }); + it('should show a retry countdown and update pending history over time', async () => { vi.useFakeTimers(); try { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index e59112f3d..8195b91e0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1285,9 +1285,12 @@ export const useGeminiStream = ( dualOutput?.processEvent(event); switch (event.type) { case ServerGeminiEventType.Thought: - // If the thought has a subject, it's a discrete status update rather than - // a streamed textual thought, so we update the thought state directly. - if (event.value.subject) { + // Subject-only chunks are discrete status updates for the + // loading indicator and render immediately. Anything carrying + // streamed text (with or without a subject) goes through the + // throttled buffer so it batches with adjacent reasoning + // chunks; the flush merger preserves the subject. + if (event.value.subject && !event.value.description) { flushBufferedStreamEvents(); setThought(event.value); } else {