feat(core): Log invalid stream type (#9168)

This commit is contained in:
Sandy Tao
2025-09-22 16:39:44 -07:00
committed by GitHub
parent 6328ca512f
commit 4cdf9207f3
2 changed files with 31 additions and 20 deletions
+9 -9
View File
@@ -13,7 +13,7 @@ import type {
import type { ContentGenerator } from '../core/contentGenerator.js'; import type { ContentGenerator } from '../core/contentGenerator.js';
import { import {
GeminiChat, GeminiChat,
EmptyStreamError, InvalidStreamError,
StreamEventType, StreamEventType,
type StreamEvent, type StreamEvent,
} from './geminiChat.js'; } from './geminiChat.js';
@@ -237,7 +237,7 @@ describe('GeminiChat', () => {
/* consume stream */ /* consume stream */
} }
})(), })(),
).rejects.toThrow(EmptyStreamError); ).rejects.toThrow(InvalidStreamError);
}); });
it('should succeed if the stream ends with an invalid part but has a finishReason and contained a valid part', async () => { it('should succeed if the stream ends with an invalid part but has a finishReason and contained a valid part', async () => {
@@ -501,14 +501,14 @@ describe('GeminiChat', () => {
'prompt-id-stream-1', 'prompt-id-stream-1',
); );
// 4. Assert: The stream processing should throw an EmptyStreamError. // 4. Assert: The stream processing should throw an InvalidStreamError.
await expect( await expect(
(async () => { (async () => {
for await (const _ of stream) { for await (const _ of stream) {
// This loop consumes the stream to trigger the internal logic. // This loop consumes the stream to trigger the internal logic.
} }
})(), })(),
).rejects.toThrow(EmptyStreamError); ).rejects.toThrow(InvalidStreamError);
}); });
it('should succeed when there is a tool call without finish reason', async () => { it('should succeed when there is a tool call without finish reason', async () => {
@@ -554,7 +554,7 @@ describe('GeminiChat', () => {
).resolves.not.toThrow(); ).resolves.not.toThrow();
}); });
it('should throw EmptyStreamError when no tool call and no finish reason', async () => { it('should throw InvalidStreamError when no tool call and no finish reason', async () => {
// Setup: Stream with text but no finish reason and no tool call // Setup: Stream with text but no finish reason and no tool call
const streamWithoutFinishReason = (async function* () { const streamWithoutFinishReason = (async function* () {
yield { yield {
@@ -586,10 +586,10 @@ describe('GeminiChat', () => {
// consume stream // consume stream
} }
})(), })(),
).rejects.toThrow(EmptyStreamError); ).rejects.toThrow(InvalidStreamError);
}); });
it('should throw EmptyStreamError when no tool call and empty response text', async () => { it('should throw InvalidStreamError when no tool call and empty response text', async () => {
// Setup: Stream with finish reason but empty response (only thoughts) // Setup: Stream with finish reason but empty response (only thoughts)
const streamWithEmptyResponse = (async function* () { const streamWithEmptyResponse = (async function* () {
yield { yield {
@@ -621,7 +621,7 @@ describe('GeminiChat', () => {
// consume stream // consume stream
} }
})(), })(),
).rejects.toThrow(EmptyStreamError); ).rejects.toThrow(InvalidStreamError);
}); });
it('should succeed when there is finish reason and response text', async () => { it('should succeed when there is finish reason and response text', async () => {
@@ -889,7 +889,7 @@ describe('GeminiChat', () => {
for await (const _ of stream) { for await (const _ of stream) {
// Must loop to trigger the internal logic that throws. // Must loop to trigger the internal logic that throws.
} }
}).rejects.toThrow(EmptyStreamError); }).rejects.toThrow(InvalidStreamError);
// Should be called 3 times (initial + 2 retries) // Should be called 3 times (initial + 2 retries)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
+22 -11
View File
@@ -161,13 +161,16 @@ function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
} }
/** /**
* Custom error to signal that a stream completed without valid content, * Custom error to signal that a stream completed with invalid content,
* which should trigger a retry. * which should trigger a retry.
*/ */
export class EmptyStreamError extends Error { export class InvalidStreamError extends Error {
constructor(message: string) { readonly type: 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT';
constructor(message: string, type: 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT') {
super(message); super(message);
this.name = 'EmptyStreamError'; this.name = 'InvalidStreamError';
this.type = type;
} }
} }
@@ -284,7 +287,7 @@ export class GeminiChat {
break; break;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
const isContentError = error instanceof EmptyStreamError; const isContentError = error instanceof InvalidStreamError;
if (isContentError) { if (isContentError) {
// Check if we have more attempts left. // Check if we have more attempts left.
@@ -293,7 +296,7 @@ export class GeminiChat {
self.config, self.config,
new ContentRetryEvent( new ContentRetryEvent(
attempt, attempt,
'EmptyStreamError', (error as InvalidStreamError).type,
INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs, INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs,
), ),
); );
@@ -312,12 +315,12 @@ export class GeminiChat {
} }
if (lastError) { if (lastError) {
if (lastError instanceof EmptyStreamError) { if (lastError instanceof InvalidStreamError) {
logContentRetryFailure( logContentRetryFailure(
self.config, self.config,
new ContentRetryFailureEvent( new ContentRetryFailureEvent(
INVALID_CONTENT_RETRY_OPTIONS.maxAttempts, INVALID_CONTENT_RETRY_OPTIONS.maxAttempts,
'EmptyStreamError', (lastError as InvalidStreamError).type,
), ),
); );
} }
@@ -564,9 +567,17 @@ export class GeminiChat {
// - No finish reason, OR // - No finish reason, OR
// - Empty response text (e.g., only thoughts with no actual content) // - Empty response text (e.g., only thoughts with no actual content)
if (!hasToolCall && (!hasFinishReason || !responseText)) { if (!hasToolCall && (!hasFinishReason || !responseText)) {
throw new EmptyStreamError( if (!hasFinishReason) {
'Model stream ended with an invalid chunk or missing finish reason.', throw new InvalidStreamError(
); 'Model stream ended without a finish reason.',
'NO_FINISH_REASON',
);
} else {
throw new InvalidStreamError(
'Model stream ended with empty response text.',
'NO_RESPONSE_TEXT',
);
}
} }
this.history.push({ role: 'model', parts: consolidatedParts }); this.history.push({ role: 'model', parts: consolidatedParts });