From 88a12098d416c4c83846c89e99665a9ba468d319 Mon Sep 17 00:00:00 2001 From: "gemini-cli[bot]" Date: Thu, 14 May 2026 23:43:01 +0000 Subject: [PATCH] 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 --- packages/core/src/core/turn.test.ts | 38 +++++++++++++++++++++++++++++ packages/core/src/core/turn.ts | 19 ++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index be94945476..615cb47541 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -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 { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index cc5335981a..f4703ecc53 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -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; }