diff --git a/packages/cli/src/acp/acpSession.test.ts b/packages/cli/src/acp/acpSession.test.ts index 96e25ea90a0..cf8a011dc62 100644 --- a/packages/cli/src/acp/acpSession.test.ts +++ b/packages/cli/src/acp/acpSession.test.ts @@ -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( diff --git a/packages/cli/src/acp/acpSession.ts b/packages/cli/src/acp/acpSession.ts index 430670d4926..875716e9374 100644 --- a/packages/cli/src/acp/acpSession.ts +++ b/packages/cli/src/acp/acpSession.ts @@ -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. diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 14d7ae22fbe..8a036ec522b 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 00b48be9540..ce3092b81f9 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -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, diff --git a/packages/cli/src/nonInteractiveCliAgentSession.test.ts b/packages/cli/src/nonInteractiveCliAgentSession.test.ts index 77920f18790..3ceeb643909 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.test.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.test.ts @@ -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', () => { diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index e0a532becf5..f052e007010 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -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; } diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index 1e0113b784c..106a779e586 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -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 || diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 1fa4250e718..9a35305cb67 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -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', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index ac63733fa99..9458c6a4ff4 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -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, ], ); diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts index 80ec96be109..b32b447fc2f 100644 --- a/packages/core/src/agent/event-translator.test.ts +++ b/packages/core/src/agent/event-translator.test.ts @@ -516,10 +516,34 @@ describe('translateEvent', () => { }); describe('InvalidStream events', () => { - it('emits fatal error', () => { + it('emits fatal error with specific message from event', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Empty response'); + expect(err.fatal).toBe(true); + expect(err._meta?.['code']).toBe('INVALID_STREAM'); + expect(err._meta?.['errorType']).toBe('NO_RESPONSE_TEXT'); + expect(err._meta?.['rawMessage']).toBe('Empty response'); + }); + + it('falls back to default message when message is missing', () => { state.streamStartEmitted = true; const event: ServerGeminiStreamEvent = { type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: '', + }, }; const result = translateEvent(event, state); expect(result).toHaveLength(1); diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index f60822a8e6f..10a5149a2be 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -222,8 +222,15 @@ export function translateEvent( out.push( makeEvent('error', state, { status: 'INTERNAL', - message: 'Invalid stream received from model', + message: + event.value?.message?.trim() || + 'Invalid stream received from model', fatal: true, + _meta: { + code: 'INVALID_STREAM', + errorType: event.value?.type, + rawMessage: event.value?.message, + }, }), ); break; diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index e8d5e56ef5c..41cebcc007e 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -10,7 +10,7 @@ */ import { GeminiEventType } from '../core/turn.js'; -import type { Part } from '@google/genai'; +import type { Part, FinishReason } from '@google/genai'; import type { GeminiClient } from '../core/client.js'; import type { Config } from '../config/config.js'; import type { ToolCallRequestInfo } from '../scheduler/types.js'; @@ -192,6 +192,7 @@ export class LegacyAgentProtocol implements AgentProtocol { } const toolCallRequests: ToolCallRequestInfo[] = []; + let finishedReason: FinishReason | undefined = undefined; const responseStream = this._client.sendMessageStream( currentParts, this._abortController.signal, @@ -220,10 +221,7 @@ export class LegacyAgentProtocol implements AgentProtocol { this._finishStream('failed'); return; case GeminiEventType.Finished: - if (toolCallRequests.length === 0) { - this._finishStream(mapFinishReason(event.value.reason)); - return; - } + finishedReason = event.value.reason; break; case GeminiEventType.AgentExecutionStopped: case GeminiEventType.UserCancelled: @@ -241,7 +239,11 @@ export class LegacyAgentProtocol implements AgentProtocol { } if (toolCallRequests.length === 0) { - this._finishStream('completed'); + if (finishedReason !== undefined) { + this._finishStream(mapFinishReason(finishedReason)); + } else { + this._finishStream('completed'); + } return; } diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 8ef003b3668..9f81fca2b43 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -165,6 +165,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -361,6 +371,16 @@ An approved plan is available for this task at \`../plans/feature-x.md\`. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -671,6 +691,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -845,6 +875,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -1005,6 +1045,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -1148,6 +1198,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -1837,6 +1897,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -2011,6 +2081,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -2189,6 +2269,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -2367,6 +2457,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -2541,6 +2641,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -2709,6 +2819,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -2851,6 +2971,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -3025,6 +3155,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -3345,6 +3485,16 @@ You are operating with a persistent file-based task tracking system located at \ - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -3774,6 +3924,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -3948,6 +4108,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -4241,6 +4411,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. @@ -4415,6 +4595,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. diff --git a/packages/core/src/core/agentChatHistory.test.ts b/packages/core/src/core/agentChatHistory.test.ts new file mode 100644 index 00000000000..2e10478b3d5 --- /dev/null +++ b/packages/core/src/core/agentChatHistory.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js'; + +describe('AgentChatHistory', () => { + const dummyTurns: HistoryTurn[] = [ + { + id: 'turn-1', + content: { role: 'user', parts: [{ text: 'Hello' }] }, + }, + { + id: 'turn-2', + content: { role: 'model', parts: [{ text: 'Hi there' }] }, + }, + { + id: 'turn-3', + content: { role: 'user', parts: [{ text: 'How are you?' }] }, + }, + ]; + + it('should initialize with empty history by default', () => { + const history = new AgentChatHistory(); + expect(history.length).toBe(0); + expect(history.get()).toEqual([]); + }); + + it('should initialize with provided turns', () => { + const history = new AgentChatHistory(dummyTurns); + expect(history.length).toBe(3); + expect(history.get()).toEqual(dummyTurns); + }); + + it('should push new turns', () => { + const history = new AgentChatHistory(); + history.push(dummyTurns[0]); + expect(history.length).toBe(1); + expect(history.get()[0]).toEqual(dummyTurns[0]); + }); + + it('should set and overwrite history turns', () => { + const history = new AgentChatHistory(dummyTurns.slice(0, 1)); + expect(history.length).toBe(1); + history.set(dummyTurns); + expect(history.length).toBe(3); + expect(history.get()).toEqual(dummyTurns); + }); + + it('should clear history', () => { + const history = new AgentChatHistory(dummyTurns); + expect(history.length).toBe(3); + history.clear(); + expect(history.length).toBe(0); + expect(history.get()).toEqual([]); + }); + + describe('rollback', () => { + it('should roll back history to a specified length', () => { + const history = new AgentChatHistory(dummyTurns); + history.rollback(1); + expect(history.length).toBe(1); + expect(history.get()).toEqual([dummyTurns[0]]); + }); + + it('should roll back to 0', () => { + const history = new AgentChatHistory(dummyTurns); + history.rollback(0); + expect(history.length).toBe(0); + expect(history.get()).toEqual([]); + }); + + it('should do nothing if rollback length is out of bounds (negative)', () => { + const history = new AgentChatHistory(dummyTurns); + history.rollback(-1); + expect(history.length).toBe(3); + expect(history.get()).toEqual(dummyTurns); + }); + + it('should do nothing if rollback length is out of bounds (greater than current history length)', () => { + const history = new AgentChatHistory(dummyTurns); + history.rollback(5); + expect(history.length).toBe(3); + expect(history.get()).toEqual(dummyTurns); + }); + }); + + it('should return raw Content array via getContents()', () => { + const history = new AgentChatHistory(dummyTurns); + expect(history.getContents()).toEqual( + dummyTurns.map((turn) => turn.content), + ); + }); + + it('should support mapping and flatMapping operations', () => { + const history = new AgentChatHistory(dummyTurns); + const mappedIds = history.map((turn) => turn.id); + expect(mappedIds).toEqual(['turn-1', 'turn-2', 'turn-3']); + + const flatMappedParts = history.flatMap((turn) => turn.content.parts || []); + expect(flatMappedParts).toEqual([ + { text: 'Hello' }, + { text: 'Hi there' }, + { text: 'How are you?' }, + ]); + }); +}); diff --git a/packages/core/src/core/agentChatHistory.ts b/packages/core/src/core/agentChatHistory.ts index 0bcd1576aac..7fd31616203 100644 --- a/packages/core/src/core/agentChatHistory.ts +++ b/packages/core/src/core/agentChatHistory.ts @@ -46,6 +46,16 @@ export class AgentChatHistory { this.history = []; } + /** + * Rolls back the history to a specified length. + * Useful when a stream fails and we need to remove the un-responded turn(s). + */ + rollback(length: number) { + if (length >= 0 && length <= this.history.length) { + this.history = this.history.slice(0, length); + } + } + get(): readonly HistoryTurn[] { return this.history; } diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 1524ef3b616..6443df5824e 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -884,6 +884,555 @@ describe('GeminiChat', () => { ).resolves.not.toThrow(); }); + it('should roll back the un-responded user turn from history when InvalidStreamError is thrown', async () => { + const initialHistoryLength = chat.agentHistory.length; + + // Setup: Stream with text but no finish reason and no tool call (will trigger InvalidStreamError) + const streamWithoutFinishReason = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'some response' }], + }, + // No finishReason + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + streamWithoutFinishReason, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test message to roll back', + 'prompt-id-rollback', + new AbortController().signal, + LlmRole.MAIN, + ); + + // Verify the user turn WAS added during sendMessageStream + expect(chat.agentHistory.length).toBe(initialHistoryLength + 1); + expect(chat.getHistory()[initialHistoryLength].parts?.[0]?.text).toBe( + 'test message to roll back', + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream to trigger validation error + } + })(), + ).rejects.toThrow(InvalidStreamError); + + // Verify history has been rolled back to its initial state + expect(chat.agentHistory.length).toBe(initialHistoryLength); + }); + + it('should preserve function responses during rollback when InvalidStreamError is thrown', async () => { + // 1. Setup history ending with a model turn containing functionCall + chat.agentHistory.push({ + id: 'model-turn-1', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + args: {}, + }, + }, + ], + }, + }); + + const initialHistoryLength = chat.agentHistory.length; + + // Setup: Stream that will throw InvalidStreamError + const streamWithNoResponseText = (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + streamWithNoResponseText, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + [ + { + functionResponse: { + name: 'test_tool', + response: { success: true }, + }, + }, + ], + 'prompt-id-function-response-rollback', + new AbortController().signal, + LlmRole.MAIN, + ); + + // Verify the function response was added + expect(chat.agentHistory.length).toBe(initialHistoryLength + 1); + + await expect( + (async () => { + for await (const _ of stream) { + // consume + } + })(), + ).rejects.toThrow(InvalidStreamError); + + // Verify that history was NOT rolled back, i.e., function response is preserved! + expect(chat.agentHistory.length).toBe(initialHistoryLength + 1); + const lastTurn = chat.agentHistory.get()[chat.agentHistory.length - 1]; + expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined(); + }); + + it('should preserve mixed multimodal function responses during rollback when InvalidStreamError is thrown (regression)', async () => { + // 1. Setup history ending with a model turn containing functionCall + chat.agentHistory.push({ + id: 'model-turn-1', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + args: {}, + }, + }, + ], + }, + }); + + const initialHistoryLength = chat.agentHistory.length; + + // Setup: Stream that will throw InvalidStreamError + const streamWithNoResponseText = (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + streamWithNoResponseText, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + [ + { + functionResponse: { + name: 'test_tool', + response: { success: true }, + }, + }, + { + fileData: { + mimeType: 'image/png', + fileUri: 'https://example.com/image.png', + }, + }, + ], + 'prompt-id-mixed-multimodal-rollback', + new AbortController().signal, + LlmRole.MAIN, + ); + + // Verify the function response was added + expect(chat.agentHistory.length).toBe(initialHistoryLength + 1); + + await expect( + (async () => { + for await (const _ of stream) { + // consume + } + })(), + ).rejects.toThrow(InvalidStreamError); + + // Verify that history was NOT rolled back, i.e., function response and sibling fileData are preserved! + expect(chat.agentHistory.length).toBe(initialHistoryLength + 1); + const lastTurn = chat.agentHistory.get()[chat.agentHistory.length - 1]; + expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined(); + expect(lastTurn.content.parts?.[1]?.fileData).toBeDefined(); + }); + + it('should restore the lastPromptTokenCount baseline on history rollback when InvalidStreamError is thrown', async () => { + // Establish an initial token count baseline + const initialBaseline = chat.getLastPromptTokenCount(); + + // Setup: Stream that yields usageMetadata updating token count and then throws an InvalidStreamError + const streamWithUsageAndFailure = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: initialBaseline + 500, // mock updated larger token count + candidatesTokenCount: 10, + totalTokenCount: initialBaseline + 510, + }, + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + streamWithUsageAndFailure, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test prompt for token baseline rollback', + 'prompt-id-baseline-rollback', + new AbortController().signal, + LlmRole.MAIN, + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream to trigger validation error + } + })(), + ).rejects.toThrow(InvalidStreamError); + + // Verify that the prompt token count has been successfully restored to its initial baseline + expect(chat.getLastPromptTokenCount()).toBe(initialBaseline); + }); + + it('should not write failed retry attempts to the chat recording service', async () => { + const recordMessageSpy = vi.spyOn( + chat.getChatRecordingService(), + 'recordMessage', + ); + const recordSyntheticMessageSpy = vi.spyOn( + chat.getChatRecordingService(), + 'recordSyntheticMessage', + ); + + // Attempt 1: returns invalid stream (triggering InvalidStreamError) + vi.mocked(mockContentGenerator.generateContentStream) + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [{ text: '' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ) + // Attempt 2: returns a valid response + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'successful retry response' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test prompt for recording deferral', + 'prompt-id-recording-deferral', + new AbortController().signal, + LlmRole.MAIN, + ); + + for await (const _ of stream) { + // consume stream completely + } + + // 1. The execution was successful, and final history turn has the correct text + const lastHistoryTurn = + chat.agentHistory.get()[chat.agentHistory.length - 1]; + expect(lastHistoryTurn.content.parts?.[0]?.text).toBe( + 'successful retry response', + ); + + // 2. recordMessage was only called for the successful turn + // (The failed attempt was NEVER recorded!) + const successfulCalls = recordMessageSpy.mock.calls.filter((call) => { + const payload = call[0]; + return ( + typeof payload === 'object' && + payload !== null && + payload.content === 'successful retry response' + ); + }); + const failedCalls = recordMessageSpy.mock.calls.filter((call) => { + const payload = call[0]; + return ( + typeof payload === 'object' && + payload !== null && + payload.content === '' + ); + }); + + expect(successfulCalls.length).toBe(1); + expect(failedCalls.length).toBe(0); + expect(recordSyntheticMessageSpy).not.toHaveBeenCalled(); + + recordMessageSpy.mockRestore(); + recordSyntheticMessageSpy.mockRestore(); + }); + + it('should not record thoughts or usage metadata to chatRecordingService from failed stream attempts', async () => { + const recordThoughtSpy = vi.spyOn( + chat.getChatRecordingService(), + 'recordThought', + ); + const recordMessageTokensSpy = vi.spyOn( + chat.getChatRecordingService(), + 'recordMessageTokens', + ); + + // Attempt 1: returns invalid stream with thoughts and usage metadata (triggering InvalidStreamError) + vi.mocked(mockContentGenerator.generateContentStream) + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + thought: true, + text: '**Stale subject** Stale description', + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 1000, + candidatesTokenCount: 50, + totalTokenCount: 1050, + }, + } as unknown as GenerateContentResponse; + })(), + ) + // Attempt 2: returns a valid response with separate thoughts and usage metadata + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + thought: true, + text: '**Fresh subject** Fresh description', + }, + { text: 'successful retry response' }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 2000, + candidatesTokenCount: 100, + totalTokenCount: 2100, + }, + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test prompt for metadata deferral', + 'prompt-id-metadata-deferral', + new AbortController().signal, + LlmRole.MAIN, + ); + + for await (const _ of stream) { + // consume stream completely + } + + // Verify that recordThought was NOT called with the first (failed) attempt's thoughts + expect(recordThoughtSpy).toHaveBeenCalledTimes(1); + expect(recordThoughtSpy).toHaveBeenCalledWith({ + subject: 'Fresh subject', + description: 'Fresh description', + }); + expect(recordThoughtSpy).not.toHaveBeenCalledWith({ + subject: 'Stale subject', + description: 'Stale description', + }); + + // Verify that recordMessageTokens was only called with the second (successful) attempt's metadata + expect(recordMessageTokensSpy).toHaveBeenCalledTimes(1); + expect(recordMessageTokensSpy).toHaveBeenCalledWith({ + promptTokenCount: 2000, + candidatesTokenCount: 100, + totalTokenCount: 2100, + }); + + // Verify that the prompt token count is correct + expect(chat.getLastPromptTokenCount()).toBe(2000); + + recordThoughtSpy.mockRestore(); + recordMessageTokensSpy.mockRestore(); + }); + + it('should sync the chat recording service on history rollback when InvalidStreamError is thrown', async () => { + const initialHistoryLength = chat.agentHistory.length; + const updateSpy = vi.spyOn( + chat.getChatRecordingService(), + 'updateMessagesFromHistory', + ); + + // Setup: Stream with text but no finish reason and no tool call (will trigger InvalidStreamError) + const streamWithoutFinishReason = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'some response' }], + }, + // No finishReason + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + streamWithoutFinishReason, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test disk sync rollback', + 'prompt-id-rollback-sync', + new AbortController().signal, + LlmRole.MAIN, + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream to trigger validation error + } + })(), + ).rejects.toThrow(InvalidStreamError); + + // Verify history has been rolled back to its initial state + expect(chat.agentHistory.length).toBe(initialHistoryLength); + // Verify chatRecordingService.updateMessagesFromHistory was called to sync the disk + expect(updateSpy).toHaveBeenCalled(); + + updateSpy.mockRestore(); + }); + + it('should roll back the un-responded user turn from history when the stream is aborted/cancelled', async () => { + const initialHistoryLength = chat.agentHistory.length; + const abortController = new AbortController(); + + // Setup: Stream that aborts/fails mid-generation + const streamWithAbort = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'some text' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + abortController.abort(); + throw new Error('User aborted a request.'); + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + streamWithAbort, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test abort message', + 'prompt-id-abort', + abortController.signal, + LlmRole.MAIN, + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream to trigger abort error + } + })(), + ).rejects.toThrow(); + + // Verify history has been rolled back to its initial state + expect(chat.agentHistory.length).toBe(initialHistoryLength); + }); + + it('should roll back the un-responded user turn from history when an ApiError is thrown', async () => { + const initialHistoryLength = chat.agentHistory.length; + + // Setup: Stream that throws a standard API error + vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue( + new Error('API rate limit reached'), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test api error message', + 'prompt-id-api-error', + new AbortController().signal, + LlmRole.MAIN, + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream + } + })(), + ).rejects.toThrow('API rate limit reached'); + + // Verify history has been rolled back to its initial state + expect(chat.agentHistory.length).toBe(initialHistoryLength); + }); + 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* () { @@ -921,7 +1470,7 @@ describe('GeminiChat', () => { ).rejects.toThrow(InvalidStreamError); }); - it('should throw InvalidStreamError without retrying when no tool call and empty response text', async () => { + it('should retry when no tool call and empty response text, and succeed if a retry succeeds', async () => { vi.mocked(mockContentGenerator.generateContentStream) .mockImplementationOnce(async () => // First attempt: finish reason is present, but the stream has no @@ -939,23 +1488,71 @@ describe('GeminiChat', () => { ], } as unknown as GenerateContentResponse; })(), - ) - .mockImplementationOnce(async () => - // This would succeed if NO_RESPONSE_TEXT were retried. + ) + .mockImplementationOnce(async () => + // Second attempt: succeeds + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'valid response after retry' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'test message', + 'prompt-id-1', + new AbortController().signal, + LlmRole.MAIN, + ); + + const chunks: GenerateContentResponse[] = []; + for await (const chunk of stream) { + if (chunk.type === StreamEventType.CHUNK) { + chunks.push(chunk.value); + } + } + + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( + 2, + ); + expect(mockLogContentRetry).toHaveBeenCalledTimes(1); + expect(mockLogContentRetryFailure).not.toHaveBeenCalled(); + expect(chunks.length).toBe(2); + expect(chunks[0].candidates?.[0]?.content?.parts?.[0]?.thought).toBe( + true, + ); + expect(chunks[1].candidates?.[0]?.content?.parts?.[0]?.text).toBe( + 'valid response after retry', + ); + }); + + it('should retry when no tool call and empty response text, and throw InvalidStreamError after exhausting retries', async () => { + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => + // All attempts return empty response text (async function* () { yield { candidates: [ { content: { role: 'model', - parts: [{ text: 'valid response after retry' }], + parts: [{ thought: true, text: 'thinking...' }], }, finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; })(), - ); + ); const stream = await chat.sendMessageStream( { model: 'gemini-2.0-flash' }, @@ -972,10 +1569,11 @@ describe('GeminiChat', () => { } })(), ).rejects.toThrow(InvalidStreamError); + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( - 1, + 4, ); - expect(mockLogContentRetry).not.toHaveBeenCalled(); + expect(mockLogContentRetry).toHaveBeenCalledTimes(3); expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1); }); @@ -1117,6 +1715,157 @@ describe('GeminiChat', () => { ).toBe(true); }); + it('should throw InvalidStreamError with type MAX_TOKENS_EXCEEDED when finishReason is MAX_TOKENS and text is empty', async () => { + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => + (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [] }, + finishReason: 'MAX_TOKENS', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.5-pro' }, + 'test', + 'prompt-id-max-tokens', + new AbortController().signal, + LlmRole.MAIN, + ); + + let error: unknown; + try { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(InvalidStreamError); + expect((error as InvalidStreamError).type).toBe('MAX_TOKENS_EXCEEDED'); + }); + + it('should throw InvalidStreamError with type THINKING_ONLY_RESPONSE when response contains thoughts but text is empty', async () => { + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'thinking...' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.5-pro' }, + 'test', + 'prompt-id-thoughts-only', + new AbortController().signal, + LlmRole.MAIN, + ); + + let error: unknown; + try { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(InvalidStreamError); + expect((error as InvalidStreamError).type).toBe('THINKING_ONLY_RESPONSE'); + }); + + it('should throw InvalidStreamError when response consists only of zero-width or invisible characters', async () => { + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '\u200B\uFEFF\u200D' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.5-pro' }, + 'test', + 'prompt-id-invisible-only', + new AbortController().signal, + LlmRole.MAIN, + ); + + let error: unknown; + try { + for await (const _ of stream) { + // consume + } + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(InvalidStreamError); + expect((error as InvalidStreamError).type).toBe('NO_RESPONSE_TEXT'); + }); + + it('should throw InvalidStreamError when response consists only of HTML or Markdown comment blocks', async () => { + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.5-pro' }, + 'test', + 'prompt-id-comments-only', + new AbortController().signal, + LlmRole.MAIN, + ); + + let error: unknown; + try { + for await (const _ of stream) { + // consume + } + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(InvalidStreamError); + expect((error as InvalidStreamError).type).toBe('NO_RESPONSE_TEXT'); + }); + it('should call generateContentStream with the correct parameters', async () => { const response = (async function* () { yield { @@ -1545,6 +2294,84 @@ describe('GeminiChat', () => { ); }); + it('should append nudge message to systemInstruction on retry when InvalidStreamError occurs', async () => { + vi.mocked(mockContentGenerator.generateContentStream) + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'thinking...' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ) + .mockImplementationOnce(async () => + (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'valid response after nudge' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + chat.setSystemInstruction('Initial instruction'); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.5-pro' }, + 'test', + 'prompt-id-retry-nudge', + new AbortController().signal, + LlmRole.MAIN, + ); + + for await (const _ of stream) { + // consume + } + + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( + 2, + ); + + // First call should have original system instruction + expect( + mockContentGenerator.generateContentStream, + ).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + config: expect.objectContaining({ + systemInstruction: 'Initial instruction', + }), + }), + 'prompt-id-retry-nudge', + LlmRole.MAIN, + ); + + // Second call (retry) should have nudge message appended to systemInstruction + expect( + mockContentGenerator.generateContentStream, + ).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + config: expect.objectContaining({ + systemInstruction: + 'Initial instruction\n[System: You previously generated thoughts but failed to provide a final user-facing response. Please ensure you provide your final answer or call a tool now.]', + }), + }), + 'prompt-id-retry-nudge', + LlmRole.MAIN, + ); + }); + it('should fail after all retries on persistent invalid content and report metrics', async () => { vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( async () => @@ -1582,13 +2409,9 @@ describe('GeminiChat', () => { expect(mockLogContentRetry).toHaveBeenCalledTimes(3); expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1); - // History should still contain the user message. + // History should be rolled back to exclude the un-responded user message. const history = chat.getHistory(); - expect(history.length).toBe(1); - expect(history[0]).toEqual({ - role: 'user', - parts: [{ text: 'test' }], - }); + expect(history.length).toBe(0); }); describe('API error retry behavior', () => { @@ -3076,6 +3899,48 @@ describe('GeminiChat', () => { 'video/mp4', ); }); + + it('should preserve all synthetic binary injection turns when the stream fails', async () => { + const initialHistoryLength = chat.agentHistory.length; + const audioParts = [ + { + functionResponse: { + id: 'call-123', + name: 'read_file', + response: { + output: 'Success', + [BINARY_INJECTION_KEY]: [ + { inlineData: { mimeType: 'audio/mpeg', data: 'base64' } }, + ], + }, + }, + }, + ]; + + // Setup: Stream that throws an error + vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue( + new Error('API error during binary injection stream'), + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-pro' }, + audioParts, + 'test-id', + new AbortController().signal, + LlmRole.MAIN, + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream + } + })(), + ).rejects.toThrow('API error during binary injection stream'); + + // Verify that history has been preserved, and all 3 synthetic binary injection turns are kept. + expect(chat.agentHistory.length).toBe(initialHistoryLength + 3); + }); }); describe('recordCompletedToolCalls', () => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 56480c70f0c..93ea20ee2ac 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -225,7 +225,12 @@ export class InvalidStreamError extends Error { | 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT' | 'MALFORMED_FUNCTION_CALL' - | 'UNEXPECTED_TOOL_CALL'; + | 'UNEXPECTED_TOOL_CALL' + | 'MAX_TOKENS_EXCEEDED' + | 'SAFETY_BLOCKED' + | 'RECITATION_BLOCKED' + | 'OTHER_BLOCKED' + | 'THINKING_ONLY_RESPONSE'; constructor( message: string, @@ -233,7 +238,12 @@ export class InvalidStreamError extends Error { | 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT' | 'MALFORMED_FUNCTION_CALL' - | 'UNEXPECTED_TOOL_CALL', + | 'UNEXPECTED_TOOL_CALL' + | 'MAX_TOKENS_EXCEEDED' + | 'SAFETY_BLOCKED' + | 'RECITATION_BLOCKED' + | 'OTHER_BLOCKED' + | 'THINKING_ONLY_RESPONSE', ) { super(message); this.name = 'InvalidStreamError'; @@ -387,6 +397,9 @@ export class GeminiChat { ): Promise> { await this.sendPromise; + const historyLengthBefore = this.agentHistory.length; + const baselinePromptTokenCount = this.lastPromptTokenCount; + let streamDoneResolver: () => void; const streamDonePromise = new Promise((resolve) => { streamDoneResolver = resolve; @@ -394,6 +407,7 @@ export class GeminiChat { this.sendPromise = streamDonePromise; let userContent = createUserContent(message); + const isOriginalFunctionResponse = isFunctionResponse(userContent); const { model } = this.context.config.modelConfigService.getResolvedConfig(modelConfigKey); @@ -402,7 +416,7 @@ export class GeminiChat { // Record user input - capture complete message with all parts (text, files, images, etc.) // but skip recording function responses (tool call results) as they should be stored in tool call records - if (!isFunctionResponse(userContent)) { + if (!isOriginalFunctionResponse) { const userMessageParts = userContent.parts || []; const userMessageContent = partListUnionToString(userMessageParts); @@ -519,6 +533,7 @@ export class GeminiChat { ): AsyncGenerator { try { const maxAttempts = this.context.config.getMaxAttempts(); + let lastStreamError: unknown = undefined; for (let attempt = 0; attempt < maxAttempts; attempt++) { let isConnectionPhase = true; @@ -530,7 +545,7 @@ export class GeminiChat { // If this is a retry, update the key with the new context. const currentConfigKey = attempt > 0 - ? { ...modelConfigKey, isRetry: true } + ? { ...modelConfigKey, isRetry: true, lastStreamError } : modelConfigKey; isConnectionPhase = true; @@ -549,6 +564,10 @@ export class GeminiChat { return; } catch (error) { + if (error instanceof InvalidStreamError) { + lastStreamError = error; + } + if (error instanceof AgentExecutionStoppedError) { yield { type: StreamEventType.AGENT_EXECUTION_STOPPED, @@ -585,8 +604,7 @@ export class GeminiChat { ); const isContentError = error instanceof InvalidStreamError; - const isRetryableContentError = - isContentError && error.type !== 'NO_RESPONSE_TEXT'; + const isRetryableContentError = isContentError; const errorType = isContentError ? error.type : getRetryErrorType(error); @@ -648,6 +666,15 @@ export class GeminiChat { throw error; } } + } catch (error) { + if (!isOriginalFunctionResponse) { + this.agentHistory.rollback(historyLengthBefore); + this.chatRecordingService.updateMessagesFromHistory( + this.agentHistory.get(), + ); + this.lastPromptTokenCount = baselinePromptTokenCount; + } + throw error; } finally { streamDoneResolver!(); } @@ -770,6 +797,30 @@ export class GeminiChat { abortSignal, }; + // Apply Context-Aware Retries (On-Retry Nudging) to guide the model out of silent loops + if ( + modelConfigKey.isRetry && + modelConfigKey.lastStreamError instanceof InvalidStreamError + ) { + const lastError = modelConfigKey.lastStreamError; + let nudgeMessage = ''; + if (lastError.type === 'THINKING_ONLY_RESPONSE') { + nudgeMessage = + '\n[System: You previously generated thoughts but failed to provide a final user-facing response. Please ensure you provide your final answer or call a tool now.]'; + } else if (lastError.type === 'NO_RESPONSE_TEXT') { + nudgeMessage = + '\n[System: You previously returned an empty response with no text or thoughts. Please ensure you provide your final answer or call a tool now.]'; + } + + if (nudgeMessage) { + if (typeof config.systemInstruction === 'string') { + config.systemInstruction += nudgeMessage; + } else if (config.systemInstruction === undefined) { + config.systemInstruction = nudgeMessage; + } + } + } + let contentsToUse: Content[] = supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse) ? [...contentsForPreviewModel] @@ -1143,6 +1194,13 @@ export class GeminiChat { let hasThoughts = false; let finishReason: FinishReason | undefined; + // Buffers to prevent failed stream attempts from polluting telemetry and logs + const bufferedThoughts: Array<{ subject: string; description: string }> = + []; + let bufferedUsageMetadata: + | GenerateContentResponse['usageMetadata'] + | undefined = undefined; + // The SDK provides fully assembled FunctionCall objects in chunk.functionCalls // We use a Map to ensure we only keep the latest version of each call (by ID) const finalFunctionCallsMap = new Map(); @@ -1198,7 +1256,10 @@ export class GeminiChat { if (content.parts.some((part) => part.thought)) { // Record thoughts hasThoughts = true; - this.recordThoughtFromContent(content); + const thought = this.extractThoughtFromContent(content); + if (thought) { + bufferedThoughts.push(thought); + } } if (content.parts.some((part) => part.functionCall)) { hasToolCall = true; @@ -1226,12 +1287,9 @@ export class GeminiChat { } } - // Record token usage if this chunk has usageMetadata + // Buffer token usage if this chunk has usageMetadata if (chunk.usageMetadata) { - this.chatRecordingService.recordMessageTokens(chunk.usageMetadata); - if (chunk.usageMetadata.promptTokenCount !== undefined) { - this.lastPromptTokenCount = chunk.usageMetadata.promptTokenCount; - } + bufferedUsageMetadata = chunk.usageMetadata; } const hookSystem = this.context.config.getHookSystem(); @@ -1318,29 +1376,22 @@ export class GeminiChat { } } - const responseText = consolidatedParts + const rawResponseText = consolidatedParts .filter((part) => part.text) .map((part) => part.text) - .join('') - .trim(); + .join(''); - let id: string; - // Record model response text from the collected parts. - // Also flush when there are thoughts or a tool call (even with no text) - // so that BeforeTool hooks always see the latest transcript state. - if (responseText || hasThoughts || hasToolCall) { - id = this.chatRecordingService.recordMessage({ - model, - type: 'gemini', - content: responseText, - }); - } else { - // Still need a durable ID even if response is empty (e.g. only tool calls) - id = this.chatRecordingService.recordSyntheticMessage( - 'gemini', - consolidatedParts, - ); - } + // Clean zero-width/invisible characters and HTML comments to determine actual printable/visible content + let responseText = rawResponseText.replace( + /[\u200B-\u200D\uFEFF\u200E\u200F]/g, + '', + ); + let previous: string; + do { + previous = responseText; + responseText = responseText.replace(//g, ''); + } while (responseText !== previous); + responseText = responseText.trim(); // Stream validation logic: A stream is considered successful if: // 1. There's a tool call OR @@ -1370,6 +1421,36 @@ export class GeminiChat { ); } if (!responseText) { + if (finishReason === FinishReason.MAX_TOKENS) { + throw new InvalidStreamError( + 'Model stream ended due to token limit exhaustion (MAX_TOKENS) with empty response text.', + 'MAX_TOKENS_EXCEEDED', + ); + } + if (finishReason === FinishReason.SAFETY) { + throw new InvalidStreamError( + 'Model stream ended due to safety settings (SAFETY) with empty response text.', + 'SAFETY_BLOCKED', + ); + } + if (finishReason === FinishReason.RECITATION) { + throw new InvalidStreamError( + 'Model stream ended due to recitation settings (RECITATION) with empty response text.', + 'RECITATION_BLOCKED', + ); + } + if (finishReason === FinishReason.OTHER) { + throw new InvalidStreamError( + 'Model stream ended due to other settings (OTHER) with empty response text.', + 'OTHER_BLOCKED', + ); + } + if (hasThoughts) { + throw new InvalidStreamError( + 'Model stream ended with empty response text but contained reasoning thoughts.', + 'THINKING_ONLY_RESPONSE', + ); + } throw new InvalidStreamError( 'Model stream ended with empty response text.', 'NO_RESPONSE_TEXT', @@ -1377,6 +1458,37 @@ export class GeminiChat { } } + // Flush buffered thoughts from the successful attempt + for (const thought of bufferedThoughts) { + this.chatRecordingService.recordThought(thought); + } + + // Flush buffered usage metadata and token counts from the successful attempt + if (bufferedUsageMetadata) { + this.chatRecordingService.recordMessageTokens(bufferedUsageMetadata); + if (bufferedUsageMetadata.promptTokenCount !== undefined) { + this.lastPromptTokenCount = bufferedUsageMetadata.promptTokenCount; + } + } + + let id: string; + // Record model response text from the collected parts. + // Also flush when there are thoughts or a tool call (even with no text) + // so that BeforeTool hooks always see the latest transcript state. + if (responseText || hasThoughts || hasToolCall) { + id = this.chatRecordingService.recordMessage({ + model, + type: 'gemini', + content: responseText, + }); + } else { + // Still need a durable ID even if response is empty (e.g. only tool calls) + id = this.chatRecordingService.recordSyntheticMessage( + 'gemini', + consolidatedParts, + ); + } + this.agentHistory.push({ id, content: { role: 'model', parts: consolidatedParts }, @@ -1431,11 +1543,13 @@ export class GeminiChat { } /** - * Extracts and records thought from thought content. + * Extracts thought from thought content. */ - private recordThoughtFromContent(content: Content): void { + private extractThoughtFromContent( + content: Content, + ): { subject: string; description: string } | undefined { if (!content.parts || content.parts.length === 0) { - return; + return undefined; } const thoughtPart = content.parts[0]; @@ -1448,11 +1562,12 @@ export class GeminiChat { : ''; const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim(); - this.chatRecordingService.recordThought({ + return { subject, description, - }); + }; } + return undefined; } } diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index cd769abb0dd..92388f7e9ae 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -254,7 +254,15 @@ describe('Turn', () => { events.push(event); } - expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]); + expect(events).toEqual([ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_FINISH_REASON', + message: 'Test invalid stream', + }, + }, + ]); expect(turn.getDebugResponses().length).toBe(0); expect(reportError).not.toHaveBeenCalled(); // Should not report as error }); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 74771c4478e..793b7c8e1d2 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -105,6 +105,19 @@ export type ServerGeminiContextWindowWillOverflowEvent = { export type ServerGeminiInvalidStreamEvent = { type: GeminiEventType.InvalidStream; + value: { + type: + | 'NO_FINISH_REASON' + | 'NO_RESPONSE_TEXT' + | 'MALFORMED_FUNCTION_CALL' + | 'UNEXPECTED_TOOL_CALL' + | 'MAX_TOKENS_EXCEEDED' + | 'SAFETY_BLOCKED' + | 'RECITATION_BLOCKED' + | 'OTHER_BLOCKED' + | 'THINKING_ONLY_RESPONSE'; + message: string; + }; }; export type ServerGeminiModelInfoEvent = { @@ -408,7 +421,13 @@ export class Turn { } if (e instanceof InvalidStreamError) { - yield { type: GeminiEventType.InvalidStream }; + yield { + type: GeminiEventType.InvalidStream, + value: { + type: e.type, + message: e.message, + }, + }; return; } diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 8f4a1b841c3..b059917e6d8 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -413,6 +413,16 @@ export function renderOperationalGuidelines( - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage +- **Tool Execution Response Rules:** + 1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions: + a) Call another tool to proceed with the task. + b) Provide a user-facing text response explaining the tool output, your analysis, and next steps. + 2. You MUST NEVER return an empty response with no text and no tool calls. +- **Post-Edit Response Rules:** + 1. After an edit tool execution (e.g. ${formatToolName(EDIT_TOOL_NAME)}, ${formatToolName(WRITE_FILE_TOOL_NAME)}), you MUST ALWAYS generate a user-facing text response summarizing: + - What changes were made to the file. + - Your verification plan or next steps (e.g. running tests). + 2. You MUST NEVER return an empty response with 0 text tokens after completing an edit. - **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution. - **File Editing Collisions:** Do NOT make multiple calls to the ${formatToolName(EDIT_TOOL_NAME)} tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit. - **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive( diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index 9b5935cdbeb..33b241ab338 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -630,8 +630,8 @@ describe('Scheduler (Orchestrator)', () => { CoreToolCallStatus.Cancelled, 'Operation cancelled by user', ); - // finalizeCall is handled by the processing loop, not synchronously by cancelAll - // expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); + // finalizeCall is called synchronously by cancelAll to ensure completedBatch is populated and isActive is updated immediately + expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); expect(mockStateManager.cancelAllQueued).toHaveBeenCalledWith( 'Operation cancelled by user', ); diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index 76529c14d36..4d620cd60be 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -278,6 +278,7 @@ export class Scheduler { CoreToolCallStatus.Cancelled, 'Operation cancelled by user', ); + this.state.finalizeCall(activeCall.request.callId); } } @@ -438,6 +439,14 @@ export class Scheduler { */ private async _processNextItem(signal: AbortSignal): Promise { if (signal.aborted || this.isCancelling) { + // Finalize active calls that are terminal + const activeCalls = this.state.allActiveCalls; + for (const call of activeCalls) { + if (this.isTerminal(call.status)) { + this.state.finalizeCall(call.request.callId); + } + } + this.state.cancelAllQueued('Operation cancelled'); return false; } diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts index f82f9267d1d..24e4325a112 100644 --- a/packages/core/src/services/modelConfigService.ts +++ b/packages/core/src/services/modelConfigService.ts @@ -37,6 +37,9 @@ export interface ModelConfigKey { // Indicates whether this request originates from the primary interactive chat model. // Enables the default fallback configuration to `chat-base` when unknown. isChatModel?: boolean; + + // The last stream error that triggered this retry attempt, if any. + lastStreamError?: unknown; } export interface ModelConfig { diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index 263f904b5a0..8c91c8d71f6 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -173,6 +173,7 @@ describe('UiTelemetryService', () => { totalRequests: 1, totalErrors: 0, totalLatencyMs: 500, + errorsByType: {}, }, tokens: { input: 5, @@ -229,6 +230,7 @@ describe('UiTelemetryService', () => { totalRequests: 2, totalErrors: 0, totalLatencyMs: 1100, + errorsByType: {}, }, tokens: { input: 10, @@ -305,6 +307,9 @@ describe('UiTelemetryService', () => { totalRequests: 1, totalErrors: 1, totalLatencyMs: 300, + errorsByType: { + UNKNOWN: 1, + }, }, tokens: { input: 0, @@ -319,6 +324,42 @@ describe('UiTelemetryService', () => { }); }); + it('should track errors by error_type distinctly', () => { + const event1 = { + 'event.name': EVENT_API_ERROR, + model: 'gemini-2.5-pro', + duration_ms: 200, + error: 'Empty response', + error_type: 'NO_RESPONSE_TEXT', + } as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }; + + const event2 = { + 'event.name': EVENT_API_ERROR, + model: 'gemini-2.5-pro', + duration_ms: 250, + error: 'Malformed JSON', + error_type: 'MALFORMED_FUNCTION_CALL', + } as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }; + + const event3 = { + 'event.name': EVENT_API_ERROR, + model: 'gemini-2.5-pro', + duration_ms: 100, + error: 'Another empty response', + error_type: 'NO_RESPONSE_TEXT', + } as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }; + + service.addEvent(event1); + service.addEvent(event2); + service.addEvent(event3); + + const metrics = service.getMetrics(); + expect(metrics.models['gemini-2.5-pro'].api.errorsByType).toEqual({ + NO_RESPONSE_TEXT: 2, + MALFORMED_FUNCTION_CALL: 1, + }); + }); + it('should aggregate ApiErrorEvents and ApiResponseEvents', () => { const responseEvent = { 'event.name': EVENT_API_RESPONSE, @@ -351,6 +392,9 @@ describe('UiTelemetryService', () => { totalRequests: 2, totalErrors: 1, totalLatencyMs: 800, + errorsByType: { + UNKNOWN: 1, + }, }, tokens: { input: 5, diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 91a262b9642..b803bdde96f 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -56,6 +56,7 @@ export interface ModelMetrics { totalRequests: number; totalErrors: number; totalLatencyMs: number; + errorsByType?: Record; }; tokens: { input: number; @@ -110,6 +111,7 @@ const createInitialModelMetrics = (): ModelMetrics => ({ totalRequests: 0, totalErrors: 0, totalLatencyMs: 0, + errorsByType: {}, }, tokens: { input: 0, @@ -170,6 +172,23 @@ export class UiTelemetryService extends EventEmitter { }); } + recordSemanticValidationError(model: string, errorType: string): void { + const modelMetrics = this.getOrCreateModelMetrics(model); + modelMetrics.api.totalErrors++; + + if (!modelMetrics.api.errorsByType) { + modelMetrics.api.errorsByType = {}; + } + const type = errorType || 'INVALID_STREAM'; + modelMetrics.api.errorsByType[type] = + (modelMetrics.api.errorsByType[type] || 0) + 1; + + this.emit('update', { + metrics: this.#metrics, + lastPromptTokenCount: this.#lastPromptTokenCount, + }); + } + getMetrics(): SessionMetrics { return this.#metrics; } @@ -326,6 +345,13 @@ export class UiTelemetryService extends EventEmitter { modelMetrics.api.totalErrors++; modelMetrics.api.totalLatencyMs += event.duration_ms; + if (!modelMetrics.api.errorsByType) { + modelMetrics.api.errorsByType = {}; + } + const errorType = event.error_type || 'UNKNOWN'; + modelMetrics.api.errorsByType[errorType] = + (modelMetrics.api.errorsByType[errorType] || 0) + 1; + if (event.role) { if (!modelMetrics.roles[event.role]) { modelMetrics.roles[event.role] = createInitialRoleMetrics(); diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 7c47f77d03c..3354a831f8c 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -10,3 +10,24 @@ export const REFERENCE_CONTENT_END = '--- End of content ---'; export const DEFAULT_MAX_LINES_TEXT_FILE = 2000; export const MAX_LINE_LENGTH_TEXT_FILE = 2000; export const MAX_FILE_SIZE_MB = 20; + +export const EMPTY_RESPONSE_COMPRESS_SUGGESTION = + 'The model returned an empty text response. If your context window is near capacity, try using /compress.'; + +export const THINKING_ONLY_COMPRESS_SUGGESTION = + 'The model returned reasoning thoughts but no final response text. If your context window is near capacity, try using /compress.'; + +export const MAX_TOKENS_EXCEEDED_SUGGESTION = + 'Model response was truncated because it exceeded the token limit. Try using /compress to free up context space.'; + +export const SAFETY_BLOCKED_MESSAGE = + 'The model response was blocked due to safety settings.'; + +export const RECITATION_BLOCKED_MESSAGE = + 'The model response was blocked due to recitation/copyright filters.'; + +export const OTHER_BLOCKED_MESSAGE = + 'The model response was blocked due to other policy settings.'; + +export const TRUE_EMPTY_RESPONSE_MESSAGE = + 'The model returned an empty response with no text or thoughts. This may be a transient API issue; please try again.'; diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index fdef9beead3..97cc37433bd 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -107,6 +107,63 @@ describe('classifyGoogleError', () => { expect((result as RetryableQuotaError).retryDelayMs).toBe(9000); }); + it('should return TerminalQuotaError for MODEL_CAPACITY_EXHAUSTED when no retry delay is specified', () => { + const apiError: GoogleApiError = { + code: 429, + message: + 'No capacity available for model gemini-3.1-pro-preview on the server', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'MODEL_CAPACITY_EXHAUSTED', + domain: 'cloudcode-pa.googleapis.com', + metadata: { model: 'gemini-3.1-pro-preview' }, + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + + it('should return TerminalQuotaError for MODEL_CAPACITY_EXHAUSTED even when the domain is not a Cloud Code domain (domain-agnostic)', () => { + const apiError: GoogleApiError = { + code: 429, + message: + 'No capacity available for model gemini-3.1-pro-preview on the server', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'MODEL_CAPACITY_EXHAUSTED', + domain: 'other.googleapis.com', + metadata: { model: 'gemini-3.1-pro-preview' }, + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + + it('should return TerminalQuotaError for MODEL_CAPACITY_EXCEEDED when no retry delay is specified', () => { + const apiError: GoogleApiError = { + code: 429, + message: + 'No capacity available for model gemini-3.1-pro-preview on the server', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'MODEL_CAPACITY_EXCEEDED', + domain: 'cloudcode-pa.googleapis.com', + metadata: { model: 'gemini-3.1-pro-preview' }, + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + it('should return original error if code is not 429, 499 or 503', () => { const apiError: GoogleApiError = { code: 500, diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index 1efadb77724..9d656964fe6 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -330,6 +330,23 @@ export function classifyGoogleError(error: unknown): unknown { ); } + if ( + errorInfo.reason === 'MODEL_CAPACITY_EXHAUSTED' || + errorInfo.reason === 'MODEL_CAPACITY_EXCEEDED' + ) { + // If no server backoff delay is specified, treat capacity exhaustion as a terminal error + // to trigger immediate model fallback without retrying on the same exhausted model. + if (delaySeconds === undefined) { + return new TerminalQuotaError( + googleApiError.message, + googleApiError, + delaySeconds, + errorInfo.reason, + ); + } + // Otherwise, fall through to RetryableQuotaError to honor the server's requested delay. + } + // New Cloud Code API quota handling if (errorInfo.domain) { if (isCloudCodeDomain(errorInfo.domain)) { diff --git a/packages/core/src/utils/messageInspectors.test.ts b/packages/core/src/utils/messageInspectors.test.ts new file mode 100644 index 00000000000..00bd5c2b05c --- /dev/null +++ b/packages/core/src/utils/messageInspectors.test.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { isFunctionResponse, isFunctionCall } from './messageInspectors.js'; + +describe('messageInspectors', () => { + describe('isFunctionResponse', () => { + it('should return false if content role is not user', () => { + const content = { + role: 'model', + parts: [ + { + functionResponse: { + name: 'test_tool', + response: { success: true }, + }, + }, + ], + }; + expect(isFunctionResponse(content)).toBe(false); + }); + + it('should return false if content has no parts', () => { + const content = { + role: 'user', + }; + expect(isFunctionResponse(content)).toBe(false); + }); + + it('should return false if parts are empty', () => { + const content = { + role: 'user', + parts: [], + }; + expect(isFunctionResponse(content)).toBe(false); + }); + + it('should return false if none of the parts is a functionResponse', () => { + const content = { + role: 'user', + parts: [ + { + text: 'Hello world', + }, + { + fileData: { + mimeType: 'image/png', + fileUri: 'https://example.com/image.png', + }, + }, + ], + }; + expect(isFunctionResponse(content)).toBe(false); + }); + + it('should return true if all parts are functionResponses', () => { + const content = { + role: 'user', + parts: [ + { + functionResponse: { + name: 'test_tool_1', + response: { success: true }, + }, + }, + { + functionResponse: { + name: 'test_tool_2', + response: { value: 42 }, + }, + }, + ], + }; + expect(isFunctionResponse(content)).toBe(true); + }); + + it('should return true if content is a mixed multimodal tool response containing functionResponse and sibling parts', () => { + const content = { + role: 'user', + parts: [ + { + functionResponse: { + name: 'test_tool', + response: { success: true }, + }, + }, + { + fileData: { + mimeType: 'image/png', + fileUri: 'https://example.com/image.png', + }, + }, + ], + }; + expect(isFunctionResponse(content)).toBe(true); + }); + }); + + describe('isFunctionCall', () => { + it('should return false if content role is not model', () => { + const content = { + role: 'user', + parts: [ + { + functionCall: { + name: 'test_tool', + args: {}, + }, + }, + ], + }; + expect(isFunctionCall(content)).toBe(false); + }); + + it('should return false if content has no parts', () => { + const content = { + role: 'model', + }; + expect(isFunctionCall(content)).toBe(false); + }); + + it('should return false if parts are empty', () => { + const content = { + role: 'model', + parts: [], + }; + expect(isFunctionCall(content)).toBe(false); + }); + + it('should return false if none of the parts is a functionCall', () => { + const content = { + role: 'model', + parts: [ + { + text: 'I am thinking...', + }, + ], + }; + expect(isFunctionCall(content)).toBe(false); + }); + + it('should return true if all parts are functionCalls', () => { + const content = { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool_1', + args: {}, + }, + }, + { + functionCall: { + name: 'test_tool_2', + args: { query: 'foo' }, + }, + }, + ], + }; + expect(isFunctionCall(content)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/utils/messageInspectors.ts b/packages/core/src/utils/messageInspectors.ts index 9a77094694c..b40f806de83 100644 --- a/packages/core/src/utils/messageInspectors.ts +++ b/packages/core/src/utils/messageInspectors.ts @@ -10,7 +10,7 @@ export function isFunctionResponse(content: Content): boolean { return ( content.role === 'user' && !!content.parts && - content.parts.every((part) => !!part.functionResponse) + content.parts.some((part) => !!part.functionResponse) ); } @@ -18,6 +18,7 @@ export function isFunctionCall(content: Content): boolean { return ( content.role === 'model' && !!content.parts && + content.parts.length > 0 && content.parts.every((part) => !!part.functionCall) ); }