fix: handle reasoning-only responses from Gemini 2.5 Pro

- Add fallback text when Gemini 2.5 Pro returns only reasoning/thinking content
- Prevents "no assistant messages" error for models with requiredReasoningBudget
- Add comprehensive test coverage for reasoning-only and mixed content scenarios

Fixes #6999
This commit is contained in:
Roo Code 2025-08-12 15:27:26 +00:00
parent 1d8b51d7c9
commit e24d1aa7f1
2 changed files with 133 additions and 1 deletions

View file

@ -90,6 +90,95 @@ describe("GeminiHandler", () => {
)
})
it("should handle reasoning-only responses for Gemini 2.5 Pro", async () => {
// Mock a response with only reasoning/thinking content
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
yield {
candidates: [
{
content: {
parts: [
{
thought: true,
text: "Let me think about this problem step by step...",
},
],
},
},
],
}
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, thoughtsTokenCount: 15 } }
},
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have reasoning chunk, text chunk (fallback), and usage
expect(chunks.length).toBe(3)
expect(chunks[0]).toEqual({ type: "reasoning", text: "Let me think about this problem step by step..." })
expect(chunks[1]).toEqual({ type: "text", text: "[Thinking process completed]" })
expect(chunks[2]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: undefined,
reasoningTokens: 15,
totalCost: undefined,
})
})
it("should handle mixed reasoning and text content", async () => {
// Mock a response with both reasoning and text content
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
yield {
candidates: [
{
content: {
parts: [
{
thought: true,
text: "Analyzing the request...",
},
{
text: "Here is my response",
},
],
},
},
],
}
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 25 } }
},
})
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have reasoning, text, and usage chunks (no fallback text needed)
expect(chunks.length).toBe(3)
expect(chunks[0]).toEqual({ type: "reasoning", text: "Analyzing the request..." })
expect(chunks[1]).toEqual({ type: "text", text: "Here is my response" })
expect(chunks[2]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 25,
cacheReadTokens: undefined,
reasoningTokens: undefined,
totalCost: undefined,
})
})
it("should handle API errors", async () => {
const mockError = new Error("Gemini API error")
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
@ -143,6 +232,28 @@ describe("GeminiHandler", () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
it("should handle reasoning-only response in completePrompt", async () => {
// Mock a response with only reasoning/thinking content and no text
;(handler["client"].models.generateContent as any).mockResolvedValue({
text: "",
candidates: [
{
content: {
parts: [
{
thought: true,
text: "Let me analyze this request...",
},
],
},
},
],
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("[Thinking process completed]")
})
})
describe("getModel", () => {

View file

@ -94,6 +94,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
let pendingGroundingMetadata: GroundingMetadata | undefined
let hasTextContent = false
let hasReasoningContent = false
for await (const chunk of result) {
// Process candidates and their parts to separate thoughts from content
@ -110,11 +112,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
// This is a thinking/reasoning part
if (part.text) {
yield { type: "reasoning", text: part.text }
hasReasoningContent = true
}
} else {
// This is regular content
if (part.text) {
yield { type: "text", text: part.text }
hasTextContent = true
}
}
}
@ -124,6 +128,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
// Fallback to the original text property if no candidates structure
else if (chunk.text) {
yield { type: "text", text: chunk.text }
hasTextContent = true
}
if (chunk.usageMetadata) {
@ -131,6 +136,12 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
}
}
// If we only got reasoning content but no text content, yield a minimal text response
// This prevents the "no assistant messages" error for Gemini 2.5 Pro with reasoning
if (hasReasoningContent && !hasTextContent) {
yield { type: "text", text: "[Thinking process completed]" }
}
if (pendingGroundingMetadata) {
const citations = this.extractCitationsOnly(pendingGroundingMetadata)
if (citations) {
@ -201,7 +212,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
async completePrompt(prompt: string): Promise<string> {
try {
const { id: model } = this.getModel()
const { id: model, reasoning: thinkingConfig } = this.getModel()
const tools: GenerateContentConfig["tools"] = []
if (this.options.enableUrlContext) {
@ -215,6 +226,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
? { baseUrl: this.options.googleGeminiBaseUrl }
: undefined,
temperature: this.options.modelTemperature ?? 0,
thinkingConfig,
...(tools.length > 0 ? { tools } : {}),
}
@ -226,6 +238,15 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
let text = result.text ?? ""
// Handle case where model only returns reasoning/thinking content
// This can happen with Gemini 2.5 Pro when reasoning is enabled
if (!text && result.candidates?.[0]?.content?.parts) {
const hasThoughts = result.candidates[0].content.parts.some((part: any) => part.thought)
if (hasThoughts) {
text = "[Thinking process completed]"
}
}
const candidate = result.candidates?.[0]
if (candidate?.groundingMetadata) {
const citations = this.extractCitationsOnly(candidate.groundingMetadata)