fix(patch): cherry-pick f47d6c6 to release/v0.53.0-pr-28566 [CONFLICTS] (#28610)

Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
gemini-cli-robot
2026-07-31 13:37:51 -07:00
committed by GitHub
parent decc0b46c6
commit 983af5d589
28 changed files with 2281 additions and 78 deletions
+35
View File
@@ -319,6 +319,41 @@ describe('Session', () => {
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it.each([
{ type: 'MAX_TOKENS_EXCEEDED', reason: 'MAX_TOKENS' },
{ type: 'SAFETY_BLOCKED', reason: 'SAFETY' },
{ type: 'RECITATION_BLOCKED', reason: 'RECITATION' },
{ type: 'OTHER_BLOCKED', reason: 'OTHER' },
{ type: 'THINKING_ONLY_RESPONSE', reason: 'STOP' },
])(
'should gracefully handle InvalidStreamError with type $type in ACP session',
async ({ type, reason }) => {
const error = new InvalidStreamError(
`Stream failed with ${reason}`,
type as InvalidStreamError['type'],
);
mockSendMessageStream.mockImplementation(() => {
async function* errorGen(): AsyncGenerator<
ServerGeminiStreamEvent,
void,
unknown
> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
yield* [] as any;
throw error;
}
return errorGen();
});
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
},
);
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
+6 -1
View File
@@ -510,7 +510,12 @@ export class Session {
(error.type === 'NO_RESPONSE_TEXT' ||
error.type === 'NO_FINISH_REASON' ||
error.type === 'MALFORMED_FUNCTION_CALL' ||
error.type === 'UNEXPECTED_TOOL_CALL'))
error.type === 'UNEXPECTED_TOOL_CALL' ||
error.type === 'MAX_TOKENS_EXCEEDED' ||
error.type === 'SAFETY_BLOCKED' ||
error.type === 'RECITATION_BLOCKED' ||
error.type === 'OTHER_BLOCKED' ||
error.type === 'THINKING_ONLY_RESPONSE'))
) {
// The stream ended with an empty response or malformed tool call.
// Treat this as a graceful end to the model's turn rather than a crash.
+55 -10
View File
@@ -22,6 +22,7 @@ import {
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ChatRecordingService: MockChatRecordingService,
uiTelemetryService: {
getMetrics: vi.fn(),
recordSemanticValidationError: vi.fn(),
},
coreEvents: mockCoreEvents,
createWorkingStdio: vi.fn(() => ({
@@ -110,6 +112,7 @@ describe('runNonInteractive', () => {
sendMessageStream: Mock;
resumeChat: Mock;
getChatRecordingService: Mock;
getCurrentSequenceModel: Mock;
};
const MOCK_SESSION_METRICS: SessionMetrics = {
models: {},
@@ -165,6 +168,7 @@ describe('runNonInteractive', () => {
recordMessageTokens: vi.fn(),
recordToolCalls: vi.fn(),
})),
getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'),
};
mockConfig = {
@@ -193,6 +197,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -1820,7 +1825,6 @@ describe('runNonInteractive', () => {
};
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getCurrentSequenceModel = vi
.fn()
.mockReturnValue('model-1');
@@ -2298,7 +2302,13 @@ describe('runNonInteractive', () => {
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2312,7 +2322,7 @@ describe('runNonInteractive', () => {
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -2325,7 +2335,13 @@ describe('runNonInteractive', () => {
OutputFormat.STREAM_JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2341,9 +2357,7 @@ describe('runNonInteractive', () => {
const output = getWrittenOutput();
expect(output).toContain('"type":"error"');
expect(output).toContain('"severity":"error"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
);
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -2355,7 +2369,13 @@ describe('runNonInteractive', () => {
OutputFormat.JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
@@ -2371,8 +2391,33 @@ describe('runNonInteractive', () => {
const output = getWrittenOutput();
expect(output).toContain('"error": {');
expect(output).toContain('"type": "INVALID_STREAM"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Custom malformed function call message',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream malformed',
prompt_id: 'prompt-id-invalid-malformed',
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[ERROR] Custom malformed function call message\n',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
+31 -2
View File
@@ -30,6 +30,12 @@ import {
ToolErrorType,
Scheduler,
ROOT_SCHEDULER_ID,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
@@ -433,8 +439,31 @@ export async function runNonInteractive(
}
warnings.push(blockMessage);
} else if (event.type === GeminiEventType.InvalidStream) {
invalidStreamError =
'Invalid stream: The model returned an empty response or malformed tool call.';
const eventValue = event.value;
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
invalidStreamError = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
invalidStreamError = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
invalidStreamError = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
invalidStreamError = SAFETY_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
invalidStreamError = RECITATION_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'OTHER_BLOCKED') {
invalidStreamError = OTHER_BLOCKED_MESSAGE;
} else {
invalidStreamError =
eventValue?.message?.trim() ||
'Invalid stream: The model returned an empty response or malformed tool call.';
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
eventValue?.type || 'INVALID_STREAM',
);
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
@@ -22,6 +22,7 @@ import {
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ChatRecordingService: MockChatRecordingService,
uiTelemetryService: {
getMetrics: vi.fn(),
recordSemanticValidationError: vi.fn(),
},
LegacyAgentSession: original.LegacyAgentSession,
geminiPartsToContentParts: original.geminiPartsToContentParts,
@@ -199,6 +201,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -2457,6 +2460,126 @@ describe('runNonInteractive', () => {
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeUndefined();
});
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
expect(processStderrSpy).toHaveBeenCalledWith(
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.STREAM_JSON,
);
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"type":"error"');
expect(output).toContain('"severity":"error"');
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.JSON,
);
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'Empty response',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"error": {');
expect(output).toContain('"type": "INVALID_STREAM"');
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Malformed call',
},
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n');
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
});
describe('Output Sanitization', () => {
@@ -39,6 +39,12 @@ import {
geminiPartsToContentParts,
displayContentToString,
debugLogger,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
@@ -332,14 +338,17 @@ export async function runNonInteractive({
return text ? text : undefined;
};
const emitFinalSuccessResult = (): void => {
const emitFinalResult = (errorPayload?: {
type: string;
message: string;
}): void => {
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
status: errorPayload ? 'error' : 'success',
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
@@ -350,7 +359,7 @@ export async function runNonInteractive({
config.getSessionId(),
responseText,
stats,
undefined,
errorPayload,
warnings,
),
);
@@ -545,6 +554,52 @@ export async function runNonInteractive({
break;
}
case 'error': {
if (event._meta?.['code'] === 'INVALID_STREAM') {
const errorTypeVal = event._meta?.['errorType'];
const errorType =
typeof errorTypeVal === 'string' ? errorTypeVal : undefined;
let errorMessage = event.message;
if (errorType === 'NO_RESPONSE_TEXT') {
errorMessage = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (errorType === 'THINKING_ONLY_RESPONSE') {
errorMessage = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (errorType === 'MAX_TOKENS_EXCEEDED') {
errorMessage = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (errorType === 'SAFETY_BLOCKED') {
errorMessage = SAFETY_BLOCKED_MESSAGE;
} else if (errorType === 'RECITATION_BLOCKED') {
errorMessage = RECITATION_BLOCKED_MESSAGE;
} else if (errorType === 'OTHER_BLOCKED') {
errorMessage = OTHER_BLOCKED_MESSAGE;
}
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'error',
message: errorMessage,
});
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[ERROR] ${errorMessage}\n`);
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
errorType || 'INVALID_STREAM',
);
// If it's a fatal stream error, we should terminate and output final results
emitFinalResult({
type: 'INVALID_STREAM',
message: errorMessage,
});
streamEnded = true;
break;
}
if (event.fatal) {
throw reconstructFatalError(event);
}
@@ -613,7 +668,7 @@ export async function runNonInteractive({
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
}
emitFinalSuccessResult();
emitFinalResult();
streamEnded = true;
break;
}
@@ -36,6 +36,18 @@ function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
) {
return false;
}
const errorsA = a.api.errorsByType || {};
const errorsB = b.api.errorsByType || {};
const keysA = Object.keys(errorsA);
const keysB = Object.keys(errorsB);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (errorsA[key] !== errorsB[key]) {
return false;
}
}
if (
a.tokens.input !== b.tokens.input ||
a.tokens.prompt !== b.tokens.prompt ||
@@ -54,6 +54,7 @@ import {
GeminiCliOperation,
getPlanModeExitMessage,
UPDATE_TOPIC_TOOL_NAME,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -1772,6 +1773,120 @@ describe('useGeminiStream', () => {
expect(mockCancelAllToolCalls).toHaveBeenCalled();
});
it('should transition to Idle state when cancelled while a tool call is in progress and completes', async () => {
const toolCalls: TrackedToolCall[] = [
{
request: { callId: 'call1', name: 'tool1', args: {} },
status: CoreToolCallStatus.Executing,
responseSubmittedToGemini: false,
tool: {
name: 'tool1',
description: 'desc1',
build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any,
invocation: {
getDescription: () => `Mock description`,
},
startTime: Date.now(),
liveOutput: '...',
} as TrackedExecutingToolCall,
];
const { result } = await renderTestHook(toolCalls);
// State is `Responding` because a tool is running
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
// Trigger the onComplete callback with the cancelled tool call
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([
{
...toolCalls[0],
status: CoreToolCallStatus.Cancelled,
response: {
callId: 'call1',
responseParts: [],
},
} as any,
]);
}
});
// The final state should be idle because the cancelled tool call was marked as submitted
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should append cancelled tool responses to history when cancelled while a tool call is in progress and completes with response parts', async () => {
const toolCalls: TrackedToolCall[] = [
{
request: { callId: 'call1', name: 'tool1', args: {} },
status: CoreToolCallStatus.Executing,
responseSubmittedToGemini: false,
tool: {
name: 'tool1',
description: 'desc1',
build: vi.fn().mockImplementation((_) => ({
getDescription: () => `Mock description`,
})),
} as any,
invocation: {
getDescription: () => `Mock description`,
},
startTime: Date.now(),
liveOutput: '...',
} as TrackedExecutingToolCall,
];
const { result, client } = await renderTestHook(toolCalls);
// State is `Responding` because a tool is running
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
const expectedResponseParts = [
{
functionResponse: {
name: 'tool1',
id: 'call1',
response: { error: 'cancelled' },
},
},
];
// Trigger the onComplete callback with the cancelled tool call having non-empty response parts
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([
{
...toolCalls[0],
status: CoreToolCallStatus.Cancelled,
response: {
callId: 'call1',
responseParts: expectedResponseParts,
},
} as any,
]);
}
});
// Assert that addHistory was called with the combined response parts
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: expectedResponseParts,
});
// The final state should be idle because the cancelled tool call was marked as submitted
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should cancel a request when a tool is awaiting confirmation', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const toolCalls: TrackedToolCall[] = [
@@ -2306,6 +2421,68 @@ describe('useGeminiStream', () => {
);
});
});
it('should use TRUE_EMPTY_RESPONSE_MESSAGE when receiving an invalid stream event of type NO_RESPONSE_TEXT', async () => {
mockSendMessageStream.mockClear();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.InvalidStream,
value: {
type: 'NO_RESPONSE_TEXT',
message: 'empty response text',
},
};
})(),
);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: TRUE_EMPTY_RESPONSE_MESSAGE,
}),
expect.any(Number),
);
});
});
it('should use the event message when receiving a non-NO_RESPONSE_TEXT invalid stream event', async () => {
mockSendMessageStream.mockClear();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.InvalidStream,
value: {
type: 'MALFORMED_FUNCTION_CALL',
message: 'Custom malformed function call message',
},
};
})(),
);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Custom malformed function call message',
}),
expect.any(Number),
);
});
});
});
describe('handleApprovalModeChange', () => {
+92 -1
View File
@@ -14,6 +14,7 @@ import {
GitService,
UnauthorizedError,
UserPromptEvent,
uiTelemetryService,
DEFAULT_GEMINI_FLASH_MODEL,
logConversationFinishedEvent,
ConversationFinishedEvent,
@@ -45,6 +46,12 @@ import {
buildToolVisibilityContext,
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
THINKING_ONLY_COMPRESS_SUGGESTION,
MAX_TOKENS_EXCEEDED_SUGGESTION,
SAFETY_BLOCKED_MESSAGE,
RECITATION_BLOCKED_MESSAGE,
OTHER_BLOCKED_MESSAGE,
TRUE_EMPTY_RESPONSE_MESSAGE,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -54,6 +61,7 @@ import type {
ServerGeminiContentEvent as ContentEvent,
ServerGeminiFinishedEvent,
ServerGeminiStreamEvent as GeminiEvent,
ServerGeminiInvalidStreamEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
@@ -1229,6 +1237,61 @@ export const useGeminiStream = (
],
);
const handleInvalidStreamEvent = useCallback(
(
eventValue: ServerGeminiInvalidStreamEvent['value'],
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
let text =
eventValue?.message?.trim() || 'Invalid stream received from model';
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
text = TRUE_EMPTY_RESPONSE_MESSAGE;
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
text = THINKING_ONLY_COMPRESS_SUGGESTION;
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
text = MAX_TOKENS_EXCEEDED_SUGGESTION;
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
text = SAFETY_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
text = RECITATION_BLOCKED_MESSAGE;
} else if (eventValue?.type === 'OTHER_BLOCKED') {
text = OTHER_BLOCKED_MESSAGE;
}
// Log semantic error telemetry without double-counting requests
uiTelemetryService.recordSemanticValidationError(
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
eventValue?.type || 'INVALID_STREAM',
);
addItem(
{
type: MessageType.ERROR,
text,
},
userMessageTimestamp,
);
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
setThought(null); // Reset thought when there's an error
},
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
config,
geminiClient,
],
);
const handleCitationEvent = useCallback(
(text: string, userMessageTimestamp: number) => {
if (!showCitations(settings)) {
@@ -1541,8 +1604,10 @@ export const useGeminiStream = (
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// Handled transparently by the backend stream retries.
break;
case ServerGeminiEventType.InvalidStream:
// Will add the missing logic later
handleInvalidStreamEvent(event.value, userMessageTimestamp);
break;
default: {
// enforces exhaustive switch-case
@@ -1575,6 +1640,7 @@ export const useGeminiStream = (
handleChatModelEvent,
handleAgentExecutionStoppedEvent,
handleAgentExecutionBlockedEvent,
handleInvalidStreamEvent,
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
@@ -1886,6 +1952,30 @@ export const useGeminiStream = (
},
);
if (turnCancelledRef.current) {
setIsResponding(false);
const geminiTools = completedAndReadyToSubmitTools.filter(
(t) => !t.request.isClientInitiated,
);
if (geminiClient && geminiTools.length > 0) {
const combinedParts = geminiTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
if (combinedParts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
geminiClient.addHistory({
role: 'user',
parts: combinedParts,
});
}
}
const callIdsToMarkAsSubmitted = toolCalls.map(
(toolCall) => toolCall.request.callId,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
return;
}
// Finalize any client-initiated tools as soon as they are done.
const clientTools = completedAndReadyToSubmitTools.filter(
(t) => t.request.isClientInitiated,
@@ -2066,6 +2156,7 @@ export const useGeminiStream = (
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
setIsResponding,
toolCalls,
],
);