mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
94c997c9d6
commit
b3bdabcea8
2 changed files with 79 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue