From 4aab689068d7b9389960412d1eb1da99ac93d07a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 10 Dec 2025 08:09:03 +0000 Subject: [PATCH] fix: handle reasoning-only responses from OpenAI compatible models - Modified Task.ts to check for reasoning content when determining if response is empty - Added placeholder text when only reasoning is provided without regular content - Added comprehensive tests for reasoning-only response scenarios - Fixes #9959 where kimi-k2-thinking model responses were incorrectly marked as empty --- ...e-openai-compatible-reasoning-only.spec.ts | 309 ++++++++++++++++++ src/core/task/Task.ts | 14 +- 2 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/api/providers/__tests__/base-openai-compatible-reasoning-only.spec.ts diff --git a/src/api/providers/__tests__/base-openai-compatible-reasoning-only.spec.ts b/src/api/providers/__tests__/base-openai-compatible-reasoning-only.spec.ts new file mode 100644 index 0000000000..d599b448c1 --- /dev/null +++ b/src/api/providers/__tests__/base-openai-compatible-reasoning-only.spec.ts @@ -0,0 +1,309 @@ +// npx vitest run api/providers/__tests__/base-openai-compatible-reasoning-only.spec.ts + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import type { ModelInfo } from "@roo-code/types" + +import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" + +// Create mock functions +const mockCreate = vi.fn() + +// Mock OpenAI module +vi.mock("openai", () => ({ + default: vi.fn(() => ({ + chat: { + completions: { + create: mockCreate, + }, + }, + })), +})) + +// Create a concrete test implementation of the abstract base class +class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> { + constructor(apiKey: string) { + const testModels: Record<"test-model", ModelInfo> = { + "test-model": { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.5, + outputPrice: 1.5, + }, + } + + super({ + providerName: "TestProvider", + baseURL: "https://test.example.com/v1", + defaultProviderModelId: "test-model", + providerModels: testModels, + apiKey, + }) + } +} + +describe("BaseOpenAiCompatibleProvider - Reasoning Only Responses", () => { + let handler: TestOpenAiCompatibleProvider + + beforeEach(() => { + vi.clearAllMocks() + handler = new TestOpenAiCompatibleProvider("test-api-key") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("Reasoning-only responses (Issue #9959)", () => { + it("should handle responses with only reasoning content in tags", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + content: + "I need to analyze this problem carefully. The user is asking about weather, so I should provide weather information.", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should yield reasoning chunks + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ + type: "reasoning", + text: "I need to analyze this problem carefully. The user is asking about weather, so I should provide weather information.", + }) + }) + + it("should handle responses with only reasoning_content field", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + reasoning_content: "Let me think about this step by step...", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + reasoning_content: "First, I need to understand the context.", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should yield reasoning chunks + expect(chunks).toEqual([ + { type: "reasoning", text: "Let me think about this step by step..." }, + { type: "reasoning", text: "First, I need to understand the context." }, + ]) + }) + + it("should handle responses with reasoning field (alternative field name)", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + reasoning: "Analyzing the request...", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should yield reasoning chunk + expect(chunks).toEqual([{ type: "reasoning", text: "Analyzing the request..." }]) + }) + + it("should handle mixed content with reasoning in tags followed by regular text", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + content: "Let me process this request", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + content: "Here is the answer to your question.", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should yield both reasoning and text chunks + expect(chunks).toEqual([ + { type: "reasoning", text: "Let me process this request" }, + { type: "text", text: "Here is the answer to your question." }, + ]) + }) + + it("should handle tool calls embedded in thinking content", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + content: + 'I need to use a tool here\n\n\nread_file\n\n{"path": "test.txt"}\n\n', + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // The XmlMatcher should process the thinking content + // For now it will just extract the reasoning text + expect(chunks.length).toBeGreaterThan(0) + expect(chunks.some((c) => c.type === "reasoning")).toBe(true) + }) + + it("should handle empty reasoning_content (whitespace only)", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + reasoning_content: " \n\t ", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + content: "Actual response text", + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should filter out whitespace-only reasoning and only return the text + expect(chunks).toEqual([{ type: "text", text: "Actual response text" }]) + }) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 35f9011a86..26cb8d7d4c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3052,13 +3052,14 @@ export class Task extends EventEmitter implements TaskLike { // able to save the assistant's response. let didEndLoop = false - // Check if we have any content to process (text or tool uses) + // Check if we have any content to process (text, reasoning, or tool uses) const hasTextContent = assistantMessage.length > 0 + const hasReasoningContent = reasoningMessage.length > 0 const hasToolUses = this.assistantMessageContent.some( (block) => block.type === "tool_use" || block.type === "mcp_tool_use", ) - if (hasTextContent || hasToolUses) { + if (hasTextContent || hasReasoningContent || hasToolUses) { // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) @@ -3072,12 +3073,19 @@ export class Task extends EventEmitter implements TaskLike { // Build the assistant message content array const assistantContent: Array = [] - // Add text content if present + // Add text content if present, or a minimal placeholder if only reasoning was provided if (assistantMessage) { assistantContent.push({ type: "text" as const, text: assistantMessage, }) + } else if (hasReasoningContent && !hasToolUses) { + // If we only have reasoning content and no text or tools, add a minimal text block + // This ensures the assistant message has some content for the API history + assistantContent.push({ + type: "text" as const, + text: "[Reasoning provided without response text]", + }) } // Add tool_use blocks with their IDs for native protocol