From b3bdabcea82f8ac62498ecfd68ac1c42e7934f83 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 4 Dec 2025 13:42:01 +0000 Subject: [PATCH] fix: prevent duplicate text emission in Gemini provider stream processing - Added processedContent flag to track if content was yielded from candidates - Only fallback to chunk.text when no content was processed from candidates - Added comprehensive test cases to verify the fix - Fixes issue #9822 where responses were being duplicated when both candidates and text properties were present --- src/api/providers/__tests__/gemini.spec.ts | 70 ++++++++++++++++++++++ src/api/providers/gemini.ts | 11 +++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index e778524c26..c13b6a9c8f 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -90,6 +90,76 @@ describe("GeminiHandler", () => { ) }) + it("should not duplicate text when both candidates and text properties are present", async () => { + // This test verifies the fix for issue #9822 - repeated/duplicated responses + // Setup mock to return chunks with both candidates AND text properties + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + // First chunk has both candidates with parts and a text property + yield { + candidates: [ + { + content: { + parts: [{ text: "Hello from candidates" }], + }, + }, + ], + text: "Hello from candidates", // Same text in fallback property + } + // Second chunk also has both + yield { + candidates: [ + { + content: { + parts: [{ text: " world!" }], + }, + }, + ], + text: " world!", // Same text in fallback property + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have exactly 3 chunks: 'Hello from candidates', ' world!', and usage + // NOT 5 chunks (which would indicate duplication) + expect(chunks.length).toBe(3) + expect(chunks[0]).toEqual({ type: "text", text: "Hello from candidates" }) + expect(chunks[1]).toEqual({ type: "text", text: " world!" }) + expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) + }) + + it("should use fallback text property when no candidates are present", async () => { + // Setup mock to return chunks with only text property (no candidates) + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { text: "Fallback text 1" } + yield { text: " Fallback text 2" } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should properly use fallback text when candidates are not present + expect(chunks.length).toBe(3) + expect(chunks[0]).toEqual({ type: "text", text: "Fallback text 1" }) + expect(chunks[1]).toEqual({ type: "text", text: " Fallback text 2" }) + expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) + }) + it("should handle API errors", async () => { const mockError = new Error("Gemini API error") ;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 73347bdd1d..7467663b7a 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -204,6 +204,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl let toolCallCounter = 0 for await (const chunk of result) { + // Track whether we processed any content from candidates in this chunk + let processedContent = false + // Track the final structured response (per SDK pattern: candidate.finishReason) if (chunk.candidates && chunk.candidates[0]?.finishReason) { finalResponse = chunk as { responseId?: string } @@ -235,6 +238,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // This is a thinking/reasoning part if (part.text) { yield { type: "reasoning", text: part.text } + processedContent = true } } else if (part.functionCall) { // Gemini sends complete function calls in a single chunk @@ -261,18 +265,21 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } toolCallCounter++ + processedContent = true } else { // This is regular content if (part.text) { yield { type: "text", text: part.text } + processedContent = true } } } } } - // Fallback to the original text property if no candidates structure - else if (chunk.text) { + // Fallback to the original text property only if no content was processed from candidates + // This prevents duplicate text emission when both candidates and text are present + if (!processedContent && chunk.text) { yield { type: "text", text: chunk.text } }