Skip to content

Commit ce88b02

Browse files
authored
feat: add preserveReasoning flag to include reasoning in API history (#8934)
1 parent f7856ef commit ce88b02

File tree

4 files changed

+338
-1
lines changed

4 files changed

+338
-1
lines changed

packages/types/src/model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const modelInfoSchema = z.object({
6868
requiredReasoningBudget: z.boolean().optional(),
6969
supportsReasoningEffort: z.boolean().optional(),
7070
requiredReasoningEffort: z.boolean().optional(),
71+
preserveReasoning: z.boolean().optional(),
7172
supportedParameters: z.array(modelParametersSchema).optional(),
7273
inputPrice: z.number().optional(),
7374
outputPrice: z.number().optional(),

packages/types/src/providers/minimax.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const minimaxModels = {
1616
outputPrice: 1.2,
1717
cacheWritesPrice: 0,
1818
cacheReadsPrice: 0,
19+
preserveReasoning: true,
1920
description:
2021
"MiniMax M2, a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.",
2122
},

src/core/task/Task.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2373,9 +2373,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
23732373
})
23742374
}
23752375

2376+
// Check if we should preserve reasoning in the assistant message
2377+
let finalAssistantMessage = assistantMessage
2378+
if (reasoningMessage && this.api.getModel().info.preserveReasoning) {
2379+
// Prepend reasoning in XML tags to the assistant message so it's included in API history
2380+
finalAssistantMessage = `<think>${reasoningMessage}</think>\n${assistantMessage}`
2381+
}
2382+
23762383
await this.addToApiConversationHistory({
23772384
role: "assistant",
2378-
content: [{ type: "text", text: assistantMessage }],
2385+
content: [{ type: "text", text: finalAssistantMessage }],
23792386
})
23802387

23812388
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"
2+
import type { ClineProvider } from "../../webview/ClineProvider"
3+
import type { ProviderSettings, ModelInfo } from "@roo-code/types"
4+
5+
// Mock vscode module before importing Task
6+
vi.mock("vscode", () => ({
7+
workspace: {
8+
createFileSystemWatcher: vi.fn(() => ({
9+
onDidCreate: vi.fn(),
10+
onDidChange: vi.fn(),
11+
onDidDelete: vi.fn(),
12+
dispose: vi.fn(),
13+
})),
14+
getConfiguration: vi.fn(() => ({
15+
get: vi.fn(() => true),
16+
})),
17+
openTextDocument: vi.fn(),
18+
applyEdit: vi.fn(),
19+
},
20+
RelativePattern: vi.fn((base, pattern) => ({ base, pattern })),
21+
window: {
22+
createOutputChannel: vi.fn(() => ({
23+
appendLine: vi.fn(),
24+
dispose: vi.fn(),
25+
})),
26+
createTextEditorDecorationType: vi.fn(() => ({
27+
dispose: vi.fn(),
28+
})),
29+
showTextDocument: vi.fn(),
30+
activeTextEditor: undefined,
31+
},
32+
Uri: {
33+
file: vi.fn((path) => ({ fsPath: path })),
34+
parse: vi.fn((str) => ({ toString: () => str })),
35+
},
36+
Range: vi.fn(),
37+
Position: vi.fn(),
38+
WorkspaceEdit: vi.fn(() => ({
39+
replace: vi.fn(),
40+
insert: vi.fn(),
41+
delete: vi.fn(),
42+
})),
43+
ViewColumn: {
44+
One: 1,
45+
Two: 2,
46+
Three: 3,
47+
},
48+
}))
49+
50+
// Mock other dependencies
51+
vi.mock("../../services/mcp/McpServerManager", () => ({
52+
McpServerManager: {
53+
getInstance: vi.fn().mockResolvedValue(null),
54+
},
55+
}))
56+
57+
vi.mock("../../integrations/terminal/TerminalRegistry", () => ({
58+
TerminalRegistry: {
59+
releaseTerminalsForTask: vi.fn(),
60+
},
61+
}))
62+
63+
vi.mock("@roo-code/telemetry", () => ({
64+
TelemetryService: {
65+
instance: {
66+
captureTaskCreated: vi.fn(),
67+
captureTaskRestarted: vi.fn(),
68+
captureConversationMessage: vi.fn(),
69+
captureLlmCompletion: vi.fn(),
70+
captureConsecutiveMistakeError: vi.fn(),
71+
},
72+
},
73+
}))
74+
75+
describe("Task reasoning preservation", () => {
76+
let mockProvider: Partial<ClineProvider>
77+
let mockApiConfiguration: ProviderSettings
78+
let Task: any
79+
80+
beforeAll(async () => {
81+
// Import Task after mocks are set up
82+
const taskModule = await import("../Task")
83+
Task = taskModule.Task
84+
})
85+
86+
beforeEach(() => {
87+
// Mock provider with necessary methods
88+
mockProvider = {
89+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
90+
getState: vi.fn().mockResolvedValue({
91+
mode: "code",
92+
experiments: {},
93+
}),
94+
context: {
95+
globalStorageUri: { fsPath: "/test/storage" },
96+
extensionPath: "/test/extension",
97+
} as any,
98+
log: vi.fn(),
99+
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
100+
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
101+
}
102+
103+
mockApiConfiguration = {
104+
apiProvider: "anthropic",
105+
apiKey: "test-key",
106+
} as ProviderSettings
107+
})
108+
109+
it("should append reasoning to assistant message when preserveReasoning is true", async () => {
110+
// Create a task instance
111+
const task = new Task({
112+
provider: mockProvider as ClineProvider,
113+
apiConfiguration: mockApiConfiguration,
114+
task: "Test task",
115+
startTask: false,
116+
})
117+
118+
// Mock the API to return a model with preserveReasoning enabled
119+
const mockModelInfo: ModelInfo = {
120+
contextWindow: 16000,
121+
supportsPromptCache: true,
122+
preserveReasoning: true,
123+
}
124+
125+
task.api = {
126+
getModel: vi.fn().mockReturnValue({
127+
id: "test-model",
128+
info: mockModelInfo,
129+
}),
130+
}
131+
132+
// Mock the API conversation history
133+
task.apiConversationHistory = []
134+
135+
// Simulate adding an assistant message with reasoning
136+
const assistantMessage = "Here is my response to your question."
137+
const reasoningMessage = "Let me think about this step by step. First, I need to..."
138+
139+
// Spy on addToApiConversationHistory
140+
const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory")
141+
142+
// Simulate what happens in the streaming loop when preserveReasoning is true
143+
let finalAssistantMessage = assistantMessage
144+
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
145+
finalAssistantMessage = `<think>${reasoningMessage}</think>\n${assistantMessage}`
146+
}
147+
148+
await (task as any).addToApiConversationHistory({
149+
role: "assistant",
150+
content: [{ type: "text", text: finalAssistantMessage }],
151+
})
152+
153+
// Verify that reasoning was prepended in <think> tags to the assistant message
154+
expect(addToApiHistorySpy).toHaveBeenCalledWith({
155+
role: "assistant",
156+
content: [
157+
{
158+
type: "text",
159+
text: "<think>Let me think about this step by step. First, I need to...</think>\nHere is my response to your question.",
160+
},
161+
],
162+
})
163+
164+
// Verify the API conversation history contains the message with reasoning
165+
expect(task.apiConversationHistory).toHaveLength(1)
166+
expect(task.apiConversationHistory[0].content[0].text).toContain("<think>")
167+
expect(task.apiConversationHistory[0].content[0].text).toContain("</think>")
168+
expect(task.apiConversationHistory[0].content[0].text).toContain("Here is my response to your question.")
169+
expect(task.apiConversationHistory[0].content[0].text).toContain(
170+
"Let me think about this step by step. First, I need to...",
171+
)
172+
})
173+
174+
it("should NOT append reasoning to assistant message when preserveReasoning is false", async () => {
175+
// Create a task instance
176+
const task = new Task({
177+
provider: mockProvider as ClineProvider,
178+
apiConfiguration: mockApiConfiguration,
179+
task: "Test task",
180+
startTask: false,
181+
})
182+
183+
// Mock the API to return a model with preserveReasoning disabled (or undefined)
184+
const mockModelInfo: ModelInfo = {
185+
contextWindow: 16000,
186+
supportsPromptCache: true,
187+
preserveReasoning: false,
188+
}
189+
190+
task.api = {
191+
getModel: vi.fn().mockReturnValue({
192+
id: "test-model",
193+
info: mockModelInfo,
194+
}),
195+
}
196+
197+
// Mock the API conversation history
198+
task.apiConversationHistory = []
199+
200+
// Simulate adding an assistant message with reasoning
201+
const assistantMessage = "Here is my response to your question."
202+
const reasoningMessage = "Let me think about this step by step. First, I need to..."
203+
204+
// Spy on addToApiConversationHistory
205+
const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory")
206+
207+
// Simulate what happens in the streaming loop when preserveReasoning is false
208+
let finalAssistantMessage = assistantMessage
209+
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
210+
finalAssistantMessage = `<think>${reasoningMessage}</think>\n${assistantMessage}`
211+
}
212+
213+
await (task as any).addToApiConversationHistory({
214+
role: "assistant",
215+
content: [{ type: "text", text: finalAssistantMessage }],
216+
})
217+
218+
// Verify that reasoning was NOT appended to the assistant message
219+
expect(addToApiHistorySpy).toHaveBeenCalledWith({
220+
role: "assistant",
221+
content: [{ type: "text", text: "Here is my response to your question." }],
222+
})
223+
224+
// Verify the API conversation history does NOT contain reasoning
225+
expect(task.apiConversationHistory).toHaveLength(1)
226+
expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response to your question.")
227+
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<think>")
228+
})
229+
230+
it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => {
231+
// Create a task instance
232+
const task = new Task({
233+
provider: mockProvider as ClineProvider,
234+
apiConfiguration: mockApiConfiguration,
235+
task: "Test task",
236+
startTask: false,
237+
})
238+
239+
// Mock the API to return a model with preserveReasoning enabled
240+
const mockModelInfo: ModelInfo = {
241+
contextWindow: 16000,
242+
supportsPromptCache: true,
243+
preserveReasoning: true,
244+
}
245+
246+
task.api = {
247+
getModel: vi.fn().mockReturnValue({
248+
id: "test-model",
249+
info: mockModelInfo,
250+
}),
251+
}
252+
253+
// Mock the API conversation history
254+
task.apiConversationHistory = []
255+
256+
const assistantMessage = "Here is my response."
257+
const reasoningMessage = "" // Empty reasoning
258+
259+
// Spy on addToApiConversationHistory
260+
const addToApiHistorySpy = vi.spyOn(task as any, "addToApiConversationHistory")
261+
262+
// Simulate what happens in the streaming loop
263+
let finalAssistantMessage = assistantMessage
264+
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
265+
finalAssistantMessage = `<think>${reasoningMessage}</think>\n${assistantMessage}`
266+
}
267+
268+
await (task as any).addToApiConversationHistory({
269+
role: "assistant",
270+
content: [{ type: "text", text: finalAssistantMessage }],
271+
})
272+
273+
// Verify that no reasoning tags were added when reasoning is empty
274+
expect(addToApiHistorySpy).toHaveBeenCalledWith({
275+
role: "assistant",
276+
content: [{ type: "text", text: "Here is my response." }],
277+
})
278+
279+
// Verify the message doesn't contain reasoning tags
280+
expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.")
281+
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<think>")
282+
})
283+
284+
it("should handle undefined preserveReasoning (defaults to false)", async () => {
285+
// Create a task instance
286+
const task = new Task({
287+
provider: mockProvider as ClineProvider,
288+
apiConfiguration: mockApiConfiguration,
289+
task: "Test task",
290+
startTask: false,
291+
})
292+
293+
// Mock the API to return a model without preserveReasoning field (undefined)
294+
const mockModelInfo: ModelInfo = {
295+
contextWindow: 16000,
296+
supportsPromptCache: true,
297+
// preserveReasoning is undefined
298+
}
299+
300+
task.api = {
301+
getModel: vi.fn().mockReturnValue({
302+
id: "test-model",
303+
info: mockModelInfo,
304+
}),
305+
}
306+
307+
// Mock the API conversation history
308+
task.apiConversationHistory = []
309+
310+
const assistantMessage = "Here is my response."
311+
const reasoningMessage = "Some reasoning here."
312+
313+
// Simulate what happens in the streaming loop
314+
let finalAssistantMessage = assistantMessage
315+
if (reasoningMessage && task.api.getModel().info.preserveReasoning) {
316+
finalAssistantMessage = `<think>${reasoningMessage}</think>\n${assistantMessage}`
317+
}
318+
319+
await (task as any).addToApiConversationHistory({
320+
role: "assistant",
321+
content: [{ type: "text", text: finalAssistantMessage }],
322+
})
323+
324+
// Verify reasoning was NOT prepended (undefined defaults to false)
325+
expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.")
326+
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<think>")
327+
})
328+
})

0 commit comments

Comments
 (0)