chore: add removal of function call if response causes 404

This commit is contained in:
Jack Wotherspoon
2026-01-20 10:25:23 -05:00
parent 7630190038
commit eb9e7eb10e
2 changed files with 75 additions and 5 deletions

View File

@@ -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 });

View File

@@ -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.
*/