Skip to content

Commit 13d39ef

Browse files
authored
Fix(core): Do not retry if last chunk is empty with finishReason previous chunks are good (google-gemini#7859)
1 parent 885af07 commit 13d39ef

File tree

2 files changed

+62
-6
lines changed

2 files changed

+62
-6
lines changed

packages/core/src/core/geminiChat.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,58 @@ describe('GeminiChat', () => {
459459
})(),
460460
).rejects.toThrow(EmptyStreamError);
461461
});
462+
463+
it('should succeed if the stream ends with an invalid part but has a finishReason and contained a valid part', async () => {
464+
// 1. Mock a stream that sends a valid chunk, then an invalid one, but has a finish reason.
465+
const streamWithInvalidEnd = (async function* () {
466+
yield {
467+
candidates: [
468+
{
469+
content: {
470+
role: 'model',
471+
parts: [{ text: 'Initial valid content...' }],
472+
},
473+
},
474+
],
475+
} as unknown as GenerateContentResponse;
476+
// This second chunk is invalid, but the response has a finishReason.
477+
yield {
478+
candidates: [
479+
{
480+
content: {
481+
role: 'model',
482+
parts: [{ text: '' }], // Invalid part
483+
},
484+
finishReason: 'STOP',
485+
},
486+
],
487+
} as unknown as GenerateContentResponse;
488+
})();
489+
490+
vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue(
491+
streamWithInvalidEnd,
492+
);
493+
494+
// 2. Action & Assert: The stream should complete without throwing an error.
495+
const stream = await chat.sendMessageStream(
496+
{ message: 'test message' },
497+
'prompt-id-valid-then-invalid-end',
498+
);
499+
await expect(
500+
(async () => {
501+
for await (const _ of stream) {
502+
/* consume stream */
503+
}
504+
})(),
505+
).resolves.not.toThrow();
506+
507+
// 3. Verify history was recorded correctly with only the valid part.
508+
const history = chat.getHistory();
509+
expect(history.length).toBe(2); // user turn + model turn
510+
const modelTurn = history[1]!;
511+
expect(modelTurn?.parts?.length).toBe(1);
512+
expect(modelTurn?.parts![0]!.text).toBe('Initial valid content...');
513+
});
462514
it('should not consolidate text into a part that also contains a functionCall', async () => {
463515
// 1. Mock the API to stream a malformed part followed by a valid text part.
464516
const multiChunkStream = (async function* () {

packages/core/src/core/geminiChat.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@ export class GeminiChat {
612612
): AsyncGenerator<GenerateContentResponse> {
613613
const modelResponseParts: Part[] = [];
614614
let hasReceivedAnyChunk = false;
615+
let hasReceivedValidChunk = false;
615616
let hasToolCall = false;
616617
let lastChunk: GenerateContentResponse | null = null;
617618
let lastChunkIsInvalid = false;
@@ -621,6 +622,7 @@ export class GeminiChat {
621622
lastChunk = chunk;
622623

623624
if (isValidResponse(chunk)) {
625+
hasReceivedValidChunk = true;
624626
lastChunkIsInvalid = false;
625627
const content = chunk.candidates?.[0]?.content;
626628
if (content?.parts) {
@@ -658,15 +660,17 @@ export class GeminiChat {
658660
(candidate) => candidate.finishReason,
659661
);
660662

661-
// --- FIX: The entire validation block was restructured for clarity and correctness ---
662663
// Stream validation logic: A stream is considered successful if:
663664
// 1. There's a tool call (tool calls can end without explicit finish reasons), OR
664-
// 2. Both conditions are met: last chunk is valid AND any candidate has a finish reason
665+
// 2. There's a finish reason AND the last chunk is valid (or we haven't received any valid chunks)
665666
//
666-
// We throw an error only when there's no tool call AND either:
667-
// - The last chunk is invalid, OR
668-
// - No candidate in the last chunk has a finish reason
669-
if (!hasToolCall && (lastChunkIsInvalid || !hasFinishReason)) {
667+
// We throw an error only when there's no tool call AND:
668+
// - No finish reason, OR
669+
// - Last chunk is invalid after receiving valid content
670+
if (
671+
!hasToolCall &&
672+
(!hasFinishReason || (lastChunkIsInvalid && !hasReceivedValidChunk))
673+
) {
670674
throw new EmptyStreamError(
671675
'Model stream ended with an invalid chunk or missing finish reason.',
672676
);

0 commit comments

Comments
 (0)