Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const modelInfoSchema = z.object({
requiredReasoningBudget: z.boolean().optional(),
supportsReasoningEffort: z.boolean().optional(),
requiredReasoningEffort: z.boolean().optional(),
preserveReasoning: z.boolean().optional(),
supportedParameters: z.array(modelParametersSchema).optional(),
inputPrice: z.number().optional(),
outputPrice: z.number().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/providers/minimax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const minimaxModels = {
outputPrice: 1.2,
cacheWritesPrice: 0,
cacheReadsPrice: 0,
preserveReasoning: true,
description:
"MiniMax M2, a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.",
},
Expand Down
9 changes: 8 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2335,9 +2335,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
})
}

// Check if we should preserve reasoning in the assistant message
let finalAssistantMessage = assistantMessage
if (reasoningMessage && this.api.getModel().info.preserveReasoning) {
// Prepend reasoning in XML tags to the assistant message so it's included in API history
finalAssistantMessage = `<thinking>${reasoningMessage}</thinking>\n${assistantMessage}`
Copy link
Collaborator

@mrubens mrubens Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For MiniMax in particular I think they use the <think> tag. Maybe worth confirming with them exactly what the right format is to make things work better?

}

await this.addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: assistantMessage }],
content: [{ type: "text", text: finalAssistantMessage }],
})

TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
Expand Down
328 changes: 328 additions & 0 deletions src/core/task/__tests__/reasoning-preservation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"
import type { ClineProvider } from "../../webview/ClineProvider"
import type { ProviderSettings, ModelInfo } from "@roo-code/types"

// Mock vscode module before importing Task
vi.mock("vscode", () => ({
workspace: {
createFileSystemWatcher: vi.fn(() => ({
onDidCreate: vi.fn(),
onDidChange: vi.fn(),
onDidDelete: vi.fn(),
dispose: vi.fn(),
})),
getConfiguration: vi.fn(() => ({
get: vi.fn(() => true),
})),
openTextDocument: vi.fn(),
applyEdit: vi.fn(),
},
RelativePattern: vi.fn((base, pattern) => ({ base, pattern })),
window: {
createOutputChannel: vi.fn(() => ({
appendLine: vi.fn(),
dispose: vi.fn(),
})),
createTextEditorDecorationType: vi.fn(() => ({
dispose: vi.fn(),
})),
showTextDocument: vi.fn(),
activeTextEditor: undefined,
},
Uri: {
file: vi.fn((path) => ({ fsPath: path })),
parse: vi.fn((str) => ({ toString: () => str })),
},
Range: vi.fn(),
Position: vi.fn(),
WorkspaceEdit: vi.fn(() => ({
replace: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
})),
ViewColumn: {
One: 1,
Two: 2,
Three: 3,
},
}))

// Mock other dependencies
vi.mock("../../services/mcp/McpServerManager", () => ({
McpServerManager: {
getInstance: vi.fn().mockResolvedValue(null),
},
}))

vi.mock("../../integrations/terminal/TerminalRegistry", () => ({
TerminalRegistry: {
releaseTerminalsForTask: vi.fn(),
},
}))

vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureTaskCreated: vi.fn(),
captureTaskRestarted: vi.fn(),
captureConversationMessage: vi.fn(),
captureLlmCompletion: vi.fn(),
captureConsecutiveMistakeError: vi.fn(),
},
},
}))

describe("Task reasoning preservation", () => {
let mockProvider: Partial<ClineProvider>
let mockApiConfiguration: ProviderSettings
let Task: any

beforeAll(async () => {
// Import Task after mocks are set up
const taskModule = await import("../Task")
Task = taskModule.Task
})

beforeEach(() => {
// Mock provider with necessary methods
mockProvider = {
postStateToWebview: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockResolvedValue({
mode: "code",
experiments: {},
}),
context: {
globalStorageUri: { fsPath: "/test/storage" },
extensionPath: "/test/extension",
} as any,
log: vi.fn(),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
}

mockApiConfiguration = {
apiProvider: "anthropic",
apiKey: "test-key",
} as ProviderSettings
})

it("should append reasoning to assistant message when preserveReasoning is true", async () => {
// Create a task instance
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})

// Mock the API to return a model with preserveReasoning enabled
const mockModelInfo: ModelInfo = {
contextWindow: 16000,
supportsPromptCache: true,
preserveReasoning: true,
}

task.api = {
getModel: vi.fn().mockReturnValue({
id: "test-model",
info: mockModelInfo,
}),
}

// Mock the API conversation history
task.apiConversationHistory = []

// Simulate adding an assistant message with reasoning
const assistantMessage = "Here is my response to your question."
const reasoningMessage = "Let me think about this step by step. First, I need to..."

// Spy on addToApiConversationHistory
const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory")

// Simulate what happens in the streaming loop when preserveReasoning is true
let finalAssistantMessage = assistantMessage
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
finalAssistantMessage = `<thinking>${reasoningMessage}</thinking>\n${assistantMessage}`
}

await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: finalAssistantMessage }],
})

// Verify that reasoning was prepended in <thinking> tags to the assistant message
expect(addToApiHistorySpy).toHaveBeenCalledWith({
role: "assistant",
content: [
{
type: "text",
text: "<thinking>Let me think about this step by step. First, I need to...</thinking>\nHere is my response to your question.",
},
],
})

// Verify the API conversation history contains the message with reasoning
expect(task.apiConversationHistory).toHaveLength(1)
expect(task.apiConversationHistory[0].content[0].text).toContain("<thinking>")
expect(task.apiConversationHistory[0].content[0].text).toContain("</thinking>")
expect(task.apiConversationHistory[0].content[0].text).toContain("Here is my response to your question.")
expect(task.apiConversationHistory[0].content[0].text).toContain(
"Let me think about this step by step. First, I need to...",
)
})

it("should NOT append reasoning to assistant message when preserveReasoning is false", async () => {
// Create a task instance
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})

// Mock the API to return a model with preserveReasoning disabled (or undefined)
const mockModelInfo: ModelInfo = {
contextWindow: 16000,
supportsPromptCache: true,
preserveReasoning: false,
}

task.api = {
getModel: vi.fn().mockReturnValue({
id: "test-model",
info: mockModelInfo,
}),
}

// Mock the API conversation history
task.apiConversationHistory = []

// Simulate adding an assistant message with reasoning
const assistantMessage = "Here is my response to your question."
const reasoningMessage = "Let me think about this step by step. First, I need to..."

// Spy on addToApiConversationHistory
const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory")

// Simulate what happens in the streaming loop when preserveReasoning is false
let finalAssistantMessage = assistantMessage
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
finalAssistantMessage = `<thinking>${reasoningMessage}</thinking>\n${assistantMessage}`
}

await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: finalAssistantMessage }],
})

// Verify that reasoning was NOT appended to the assistant message
expect(addToApiHistorySpy).toHaveBeenCalledWith({
role: "assistant",
content: [{ type: "text", text: "Here is my response to your question." }],
})

// Verify the API conversation history does NOT contain reasoning
expect(task.apiConversationHistory).toHaveLength(1)
expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response to your question.")
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<thinking>")
})

it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => {
// Create a task instance
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})

// Mock the API to return a model with preserveReasoning enabled
const mockModelInfo: ModelInfo = {
contextWindow: 16000,
supportsPromptCache: true,
preserveReasoning: true,
}

task.api = {
getModel: vi.fn().mockReturnValue({
id: "test-model",
info: mockModelInfo,
}),
}

// Mock the API conversation history
task.apiConversationHistory = []

const assistantMessage = "Here is my response."
const reasoningMessage = "" // Empty reasoning

// Spy on addToApiConversationHistory
const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory")

// Simulate what happens in the streaming loop
let finalAssistantMessage = assistantMessage
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
finalAssistantMessage = `<thinking>${reasoningMessage}</thinking>\n${assistantMessage}`
}

await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: finalAssistantMessage }],
})

// Verify that no reasoning tags were added when reasoning is empty
expect(addToApiHistorySpy).toHaveBeenCalledWith({
role: "assistant",
content: [{ type: "text", text: "Here is my response." }],
})

// Verify the message doesn't contain reasoning tags
expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.")
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<thinking>")
})

it("should handle undefined preserveReasoning (defaults to false)", async () => {
// Create a task instance
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})

// Mock the API to return a model without preserveReasoning field (undefined)
const mockModelInfo: ModelInfo = {
contextWindow: 16000,
supportsPromptCache: true,
// preserveReasoning is undefined
}

task.api = {
getModel: vi.fn().mockReturnValue({
id: "test-model",
info: mockModelInfo,
}),
}

// Mock the API conversation history
task.apiConversationHistory = []

const assistantMessage = "Here is my response."
const reasoningMessage = "Some reasoning here."

// Simulate what happens in the streaming loop
let finalAssistantMessage = assistantMessage
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
finalAssistantMessage = `<thinking>${reasoningMessage}</thinking>\n${assistantMessage}`
}

await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: finalAssistantMessage }],
})

// Verify reasoning was NOT prepended (undefined defaults to false)
expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.")
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<thinking>")
})
})