mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
feat(core): Log invalid stream type (#9168)
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user