fix(core,a2a): group cancelled tool responses and coalesce consecutive roles to prevent 400 Bad Request (#28407)

This commit is contained in:
luisfelipe-alt
2026-07-15 12:58:55 -07:00
committed by GitHub
parent 1ae8ba6496
commit 3ff5ba20fc
3 changed files with 105 additions and 13 deletions
+59
View File
@@ -21,6 +21,7 @@ import {
type StreamEvent,
stripToolCallIdPrefixes,
type HistoryTurn,
coalesceConsecutiveRoles,
} from './geminiChat.js';
import {
type CompletedToolCall,
@@ -3137,4 +3138,62 @@ describe('GeminiChat', () => {
expect(stripped[1].parts![0].functionResponse!.id).toBe('call_123');
});
});
describe('coalesceConsecutiveRoles', () => {
it('should return empty history if empty array is passed', () => {
expect(coalesceConsecutiveRoles([])).toEqual([]);
});
it('should not modify history when roles alternate correctly', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
{
id: '3',
content: { role: 'user', parts: [{ text: 'how are you?' }] },
},
];
expect(coalesceConsecutiveRoles(history)).toEqual(history);
});
it('should coalesce consecutive user turns', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
];
expect(coalesceConsecutiveRoles(history)).toEqual([
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'hello' }, { text: 'world' }],
},
},
]);
});
it('should handle undefined or missing parts gracefully', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user' } },
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
];
expect(coalesceConsecutiveRoles(history)).toEqual([
{
id: '1',
content: {
role: 'user',
parts: [{ text: 'world' }],
},
},
]);
});
it('should not coalesce turns if roles are undefined', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { parts: [{ text: 'hello' }] } },
{ id: '2', content: { parts: [{ text: 'world' }] } },
];
expect(coalesceConsecutiveRoles(history)).toEqual(history);
});
});
});
+32 -1
View File
@@ -683,10 +683,13 @@ export class GeminiChat {
): Promise<AsyncGenerator<GenerateContentResponse>> {
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
// before sending to the Gemini API. This whitelists only standard Gemini fields.
const scrubbedHistory = this.context.config.isContextManagementEnabled()
let scrubbedHistory = this.context.config.isContextManagementEnabled()
? scrubHistory([...requestHistory])
: [...requestHistory];
// Always coalesce consecutive roles to prevent 400 Bad Request errors
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
const scrubbedContents = scrubbedHistory.map((h) => h.content);
const requestContents = apiHistoryOverride
@@ -1472,3 +1475,31 @@ export function stripToolCallIdPrefixes(contents: Content[]): Content[] {
}),
}));
}
export function coalesceConsecutiveRoles(
history: HistoryTurn[],
): HistoryTurn[] {
const result: HistoryTurn[] = [];
for (const turn of history) {
const lastIdx = result.length - 1;
const last = result[lastIdx];
if (last && last.content.role && last.content.role === turn.content.role) {
const hasParts = last.content.parts || turn.content.parts;
result[lastIdx] = {
id: last.id,
content: {
...last.content,
parts: hasParts
? [...(last.content.parts || []), ...(turn.content.parts || [])]
: undefined,
},
};
} else {
result.push({
id: turn.id,
content: { ...turn.content },
});
}
}
return result;
}