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

View File

@@ -13,7 +13,7 @@ import type {
import type { ContentGenerator } from '../core/contentGenerator.js';
import {
GeminiChat,
EmptyStreamError,
InvalidStreamError,
StreamEventType,
type StreamEvent,
} from './geminiChat.js';
@@ -237,7 +237,7 @@ describe('GeminiChat', () => {
/* 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 () => {
@@ -501,14 +501,14 @@ describe('GeminiChat', () => {
'prompt-id-stream-1',
);
// 4. Assert: The stream processing should throw an EmptyStreamError.
// 4. Assert: The stream processing should throw an InvalidStreamError.
await expect(
(async () => {
for await (const _ of stream) {
// 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 () => {
@@ -554,7 +554,7 @@ describe('GeminiChat', () => {
).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
const streamWithoutFinishReason = (async function* () {
yield {
@@ -586,10 +586,10 @@ describe('GeminiChat', () => {
// 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)
const streamWithEmptyResponse = (async function* () {
yield {
@@ -621,7 +621,7 @@ describe('GeminiChat', () => {
// consume stream
}
})(),
).rejects.toThrow(EmptyStreamError);
).rejects.toThrow(InvalidStreamError);
});
it('should succeed when there is finish reason and response text', async () => {
@@ -889,7 +889,7 @@ describe('GeminiChat', () => {
for await (const _ of stream) {
// Must loop to trigger the internal logic that throws.
}
}).rejects.toThrow(EmptyStreamError);
}).rejects.toThrow(InvalidStreamError);
// Should be called 3 times (initial + 2 retries)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(

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.
*/
export class EmptyStreamError extends Error {
constructor(message: string) {
export class InvalidStreamError extends Error {
readonly type: 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT';
constructor(message: string, type: 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT') {
super(message);
this.name = 'EmptyStreamError';
this.name = 'InvalidStreamError';
this.type = type;
}
}
@@ -284,7 +287,7 @@ export class GeminiChat {
break;
} catch (error) {
lastError = error;
const isContentError = error instanceof EmptyStreamError;
const isContentError = error instanceof InvalidStreamError;
if (isContentError) {
// Check if we have more attempts left.
@@ -293,7 +296,7 @@ export class GeminiChat {
self.config,
new ContentRetryEvent(
attempt,
'EmptyStreamError',
(error as InvalidStreamError).type,
INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs,
),
);
@@ -312,12 +315,12 @@ export class GeminiChat {
}
if (lastError) {
if (lastError instanceof EmptyStreamError) {
if (lastError instanceof InvalidStreamError) {
logContentRetryFailure(
self.config,
new ContentRetryFailureEvent(
INVALID_CONTENT_RETRY_OPTIONS.maxAttempts,
'EmptyStreamError',
(lastError as InvalidStreamError).type,
),
);
}
@@ -564,9 +567,17 @@ export class GeminiChat {
// - No finish reason, OR
// - Empty response text (e.g., only thoughts with no actual content)
if (!hasToolCall && (!hasFinishReason || !responseText)) {
throw new EmptyStreamError(
'Model stream ended with an invalid chunk or missing finish reason.',
);
if (!hasFinishReason) {
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 });