From 0a6f2e089f5f5aa1bbdedc4681b9adecf60bacc4 Mon Sep 17 00:00:00 2001 From: Pyry Takala <7336413+pyrytakala@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:04:45 -0800 Subject: [PATCH] Fix: Process all parts in response chunks when thought is first (#13539) --- packages/core/src/core/turn.test.ts | 102 +++++++++++++++++++++++++++ packages/core/src/core/turn.ts | 19 ++--- packages/core/src/utils/partUtils.ts | 2 +- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index e951d80933..43146e31ec 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -754,6 +754,108 @@ describe('Turn', () => { expect(events).toEqual([expectedEvent]); }); + + it('should process all parts when thought is first part in chunk', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + parts: [ + { text: '**Planning** the solution', thought: 'planning' }, + { text: 'I will help you with that.' }, + ], + }, + citationMetadata: { + citations: [{ uri: 'https://example.com', title: 'Source' }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [ + { + id: 'fc1', + name: 'ReadFile', + args: { path: 'file.txt' }, + }, + ], + responseId: 'trace-789', + } as unknown as GenerateContentResponse, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + for await (const event of turn.run( + { model: 'gemini' }, + [{ text: 'Test mixed content' }], + new AbortController().signal, + )) { + events.push(event); + } + + // Should yield: + // 1. Thought event (from first part) + // 2. Content event (from second part) + // 3. ToolCallRequest event (from functionCalls) + // 4. Citation event (from citationMetadata, emitted with finishReason) + // 5. Finished event (from finishReason) + + expect(events.length).toBe(5); + + const thoughtEvent = events.find( + (e) => e.type === GeminiEventType.Thought, + ); + expect(thoughtEvent).toBeDefined(); + expect(thoughtEvent).toMatchObject({ + type: GeminiEventType.Thought, + value: { subject: 'Planning', description: 'the solution' }, + traceId: 'trace-789', + }); + + const contentEvent = events.find( + (e) => e.type === GeminiEventType.Content, + ); + expect(contentEvent).toBeDefined(); + expect(contentEvent).toMatchObject({ + type: GeminiEventType.Content, + value: 'I will help you with that.', + traceId: 'trace-789', + }); + + const toolCallEvent = events.find( + (e) => e.type === GeminiEventType.ToolCallRequest, + ); + expect(toolCallEvent).toBeDefined(); + expect(toolCallEvent).toMatchObject({ + type: GeminiEventType.ToolCallRequest, + value: expect.objectContaining({ + callId: 'fc1', + name: 'ReadFile', + args: { path: 'file.txt' }, + }), + }); + + const citationEvent = events.find( + (e) => e.type === GeminiEventType.Citation, + ); + expect(citationEvent).toBeDefined(); + expect(citationEvent).toMatchObject({ + type: GeminiEventType.Citation, + value: expect.stringContaining('https://example.com'), + }); + + const finishedEvent = events.find( + (e) => e.type === GeminiEventType.Finished, + ); + expect(finishedEvent).toBeDefined(); + expect(finishedEvent).toMatchObject({ + type: GeminiEventType.Finished, + value: { reason: 'STOP' }, + }); + }); }); describe('getDebugResponses', () => { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 90d6a3cbfc..7ecd01340d 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -290,15 +290,16 @@ export class Turn { const traceId = resp.responseId; - const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0]; - if (thoughtPart?.thought) { - const thought = parseThought(thoughtPart.text ?? ''); - yield { - type: GeminiEventType.Thought, - value: thought, - traceId, - }; - continue; + const parts = resp.candidates?.[0]?.content?.parts ?? []; + for (const part of parts) { + if (part.thought) { + const thought = parseThought(part.text ?? ''); + yield { + type: GeminiEventType.Thought, + value: thought, + traceId, + }; + } } const text = getResponseText(resp); diff --git a/packages/core/src/utils/partUtils.ts b/packages/core/src/utils/partUtils.ts index f5195c11ca..5afa60d5b5 100644 --- a/packages/core/src/utils/partUtils.ts +++ b/packages/core/src/utils/partUtils.ts @@ -81,7 +81,7 @@ export function getResponseText( candidate.content.parts.length > 0 ) { return candidate.content.parts - .filter((part) => part.text) + .filter((part) => part.text && !part.thought) .map((part) => part.text) .join(''); }