mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-12 23:01:21 +00:00
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
This commit is contained in:
parent
36ef6034e0
commit
4aab689068
2 changed files with 320 additions and 3 deletions
|
|
@ -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 <think> tags", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content:
|
||||
"<think>I need to analyze this problem carefully. The user is asking about weather, so I should provide weather information.</think>",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.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 <think> tags followed by regular text", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: "<think>Let me process this request</think>",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.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:
|
||||
'<think>I need to use a tool here\n\n<use_tool>\n<tool_name>read_file</tool_name>\n<parameters>\n{"path": "test.txt"}\n</parameters>\n</use_tool></think>',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.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" }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -3052,13 +3052,14 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> implements TaskLike {
|
|||
// Build the assistant message content array
|
||||
const assistantContent: Array<Anthropic.TextBlockParam | Anthropic.ToolUseBlockParam> = []
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue