Fix: Process all parts in response chunks when thought is first (#13539)

This commit is contained in:
Pyry Takala
2026-01-19 13:04:45 -08:00
committed by GitHub
parent d8a8b434f6
commit 0a6f2e089f
3 changed files with 113 additions and 10 deletions

View File

@@ -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', () => {

View File

@@ -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);

View File

@@ -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('');
}