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
This commit is contained in:
Roo Code 2025-12-04 13:42:01 +00:00
parent 94c997c9d6
commit b3bdabcea8
2 changed files with 79 additions and 2 deletions

View file

@ -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)

View file

@ -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 }
}