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.
This commit is contained in:
tanzhenxin 2026-04-28 16:32:59 +08:00 committed by GitHub
parent e973dabf37
commit 6763124a05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 51 additions and 3 deletions

View file

@ -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 {

View file

@ -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 {