fix(core): prevent text duplication in AfterAgent hook prompt_response

Fixed a logic error in `Turn.getResponseText()` where all individual streaming chunks were being joined with a space. This lead to two issues:
1. Extra spaces between words/chunks in the output.
2. Massive duplication of the entire response text because the final consolidated response (yielded by some content generators or logic) was being joined with the individual chunks.

The new implementation uses a robust heuristic: if a chunk's text starts with the accumulated text so far, it is treated as a cumulative update and replaces the current buffer; otherwise, it is treated as a delta and appended. This handles delta chunks, cumulative chunks, and redundant consolidated responses gracefully.

Fixes #27030

cc @spencertang @mbleigh
This commit is contained in:
gemini-cli[bot]
2026-05-14 23:43:01 +00:00
parent 4293ddfcc7
commit 88a12098d4
2 changed files with 53 additions and 4 deletions
+38
View File
@@ -134,6 +134,44 @@ describe('Turn', () => {
expect(turn.getDebugResponses().length).toBe(2);
});
it('should not duplicate text in getResponseText when both chunks and consolidated response are present', async () => {
const mockResponseStream = (async function* () {
// Chunk 1
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
} as GenerateContentResponse,
};
// Chunk 2
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: ' world' }] } }],
} as GenerateContentResponse,
};
// Final consolidated response (as yielded by GeminiChat)
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [{
content: { parts: [{ text: 'Hello world' }] },
finishReason: 'STOP'
}],
} as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
for await (const _ of turn.run({ model: 'm' }, [{ text: 'req' }], new AbortController().signal)) {
// consume stream
}
const text = turn.getResponseText();
// CURRENT BUGGY BEHAVIOR: "Hello world Hello world"
expect(text).toBe('Hello world');
});
it('should yield tool_call_request events for function calls', async () => {
const mockResponseStream = (async function* () {
yield {
+15 -4
View File
@@ -472,10 +472,21 @@ export class Turn {
*/
getResponseText(): string {
if (this.cachedResponseText === undefined) {
this.cachedResponseText = this.debugResponses
.map((response) => getResponseText(response))
.filter((text): text is string => text !== null)
.join(' ');
let result = '';
for (const response of this.debugResponses) {
const text = getResponseText(response);
if (text) {
// Heuristic to handle both delta and cumulative responses:
// If the new text starts with our current result, it's likely a cumulative
// update (or a redundant final consolidated response).
if (result && text.startsWith(result)) {
result = text;
} else {
result += text;
}
}
}
this.cachedResponseText = result;
}
return this.cachedResponseText;
}