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
+14 -12
View File
@@ -1092,19 +1092,21 @@ export class Task {
logger.info(
`[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
);
const responsesToAdd = completedTools.flatMap(
(toolCall) => toolCall.response.responseParts,
);
for (const response of responsesToAdd) {
let parts: genAiPart[];
if (Array.isArray(response)) {
parts = response;
} else if (typeof response === 'string') {
parts = [{ text: response }];
} else {
parts = [response];
const parts: genAiPart[] = [];
for (const toolCall of completedTools) {
const response = toolCall.response?.responseParts;
if (!response) {
continue;
}
if (Array.isArray(response)) {
parts.push(...response);
} else if (typeof response === 'string') {
parts.push({ text: response });
} else {
parts.push(response);
}
}
if (parts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.geminiClient.addHistory({
role: 'user',
+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;
}