diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index bfce4b51dd..ec29459ad6 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1344,6 +1344,60 @@ describe('GeminiChat', () => { ).toHaveBeenCalledTimes(1); }); + it('should remove function response AND preceding function call on 400 error', async () => { + // Set up history with a user message and model function call + const initialUserMessage: Content = { + role: 'user', + parts: [{ text: 'Call a tool for me' }], + }; + const modelFunctionCall: Content = { + role: 'model', + parts: [{ functionCall: { name: 'test_tool', args: {} } }], + }; + chat.addHistory(initialUserMessage); + chat.addHistory(modelFunctionCall); + + // Verify initial history state + expect(chat.getHistory().length).toBe(2); + + const error400 = new ApiError({ message: 'Bad Request', status: 400 }); + vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue( + error400, + ); + + // Send a function response that will fail with 400 + const functionResponse = [ + { + functionResponse: { + name: 'test_tool', + response: { invalid: 'data' }, + }, + }, + ]; + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.5-flash' }, + functionResponse, + 'prompt-id-400-fn', + new AbortController().signal, + ); + + await expect( + (async () => { + for await (const _ of stream) { + /* consume stream */ + } + })(), + ).rejects.toThrow(error400); + + // History should only contain the initial user message. + // Both the function response AND the model's function call should be removed + // to avoid a dangling function call state. + const history = chat.getHistory(); + expect(history.length).toBe(1); + expect(history[0]).toEqual(initialUserMessage); + }); + it('should retry on 429 Rate Limit errors', async () => { const error429 = new ApiError({ message: 'Rate Limited', status: 429 }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 1be048f141..20975f6d05 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -41,7 +41,10 @@ import { ContentRetryFailureEvent, } from '../telemetry/types.js'; import { handleFallback } from '../fallback/handler.js'; -import { isFunctionResponse } from '../utils/messageInspectors.js'; +import { + isFunctionResponse, + isFunctionCall, +} from '../utils/messageInspectors.js'; import { partListUnionToString } from './geminiRequest.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; import { estimateTokenCountSync } from '../utils/tokenCalculation.js'; @@ -387,8 +390,7 @@ export class GeminiChat { ); if (isConnectionPhase && !isRetryable) { - // Remove failed user content to not break subsequent requests - this.history.pop(); + this.popFailedUserContent(); throw error; } @@ -432,8 +434,7 @@ export class GeminiChat { new ContentRetryFailureEvent(maxAttempts, lastError.type, model), ); } - // Remove failed user content so it doesn't break subsequent requests - this.history.pop(); + this.popFailedUserContent(); throw lastError; } } finally { @@ -678,6 +679,21 @@ export class GeminiChat { this.history = []; } + /** + * Removes failed user content from history. If the content was a function + * response, also removes the preceding model function call to keep + * history consistent (avoids dangling function call state). + */ + private popFailedUserContent(): void { + const popped = this.history.pop(); + if (popped && isFunctionResponse(popped)) { + const prev = this.history[this.history.length - 1]; + if (prev && isFunctionCall(prev)) { + this.history.pop(); + } + } + } + /** * Adds a new entry to the chat history. */