mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-17 21:40:39 -07:00
fix(core,a2a): group cancelled tool responses and coalesce consecutive roles to prevent 400 Bad Request (#28407)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user