Skip to content

Commit 71f312b

Browse files
feat: enable mergeToolResultText for Roo Code Cloud provider (#10301)
1 parent 30090de commit 71f312b

File tree

3 files changed

+225
-12
lines changed

3 files changed

+225
-12
lines changed

src/api/providers/roo.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,13 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
100100
model,
101101
max_tokens,
102102
temperature,
103-
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
103+
// Enable mergeToolResultText to merge environment_details and other text content
104+
// after tool_results into the last tool message. This prevents reasoning/thinking
105+
// models from dropping reasoning_content when they see a user message after tool results.
106+
messages: [
107+
{ role: "system", content: systemPrompt },
108+
...convertToOpenAiMessages(messages, { mergeToolResultText: true }),
109+
],
104110
stream: true,
105111
stream_options: { include_usage: true },
106112
...(reasoning && { reasoning }),

src/api/transform/__tests__/openai-format.spec.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,4 +224,181 @@ describe("convertToOpenAiMessages", () => {
224224
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
225225
expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123")
226226
})
227+
228+
describe("mergeToolResultText option", () => {
229+
it("should merge text content into last tool message when mergeToolResultText is true", () => {
230+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
231+
{
232+
role: "user",
233+
content: [
234+
{
235+
type: "tool_result",
236+
tool_use_id: "tool-123",
237+
content: "Tool result content",
238+
},
239+
{
240+
type: "text",
241+
text: "<environment_details>\nSome context\n</environment_details>",
242+
},
243+
],
244+
},
245+
]
246+
247+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })
248+
249+
// Should produce only one tool message with merged content
250+
expect(openAiMessages).toHaveLength(1)
251+
const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
252+
expect(toolMessage.role).toBe("tool")
253+
expect(toolMessage.tool_call_id).toBe("tool-123")
254+
expect(toolMessage.content).toBe(
255+
"Tool result content\n\n<environment_details>\nSome context\n</environment_details>",
256+
)
257+
})
258+
259+
it("should merge text into last tool message when multiple tool results exist", () => {
260+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
261+
{
262+
role: "user",
263+
content: [
264+
{
265+
type: "tool_result",
266+
tool_use_id: "call_1",
267+
content: "First result",
268+
},
269+
{
270+
type: "tool_result",
271+
tool_use_id: "call_2",
272+
content: "Second result",
273+
},
274+
{
275+
type: "text",
276+
text: "<environment_details>Context</environment_details>",
277+
},
278+
],
279+
},
280+
]
281+
282+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })
283+
284+
// Should produce two tool messages, with text merged into the last one
285+
expect(openAiMessages).toHaveLength(2)
286+
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe("First result")
287+
expect((openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe(
288+
"Second result\n\n<environment_details>Context</environment_details>",
289+
)
290+
})
291+
292+
it("should NOT merge text when images are present (fall back to user message)", () => {
293+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
294+
{
295+
role: "user",
296+
content: [
297+
{
298+
type: "tool_result",
299+
tool_use_id: "tool-123",
300+
content: "Tool result content",
301+
},
302+
{
303+
type: "image",
304+
source: {
305+
type: "base64",
306+
media_type: "image/png",
307+
data: "base64data",
308+
},
309+
},
310+
],
311+
},
312+
]
313+
314+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })
315+
316+
// Should produce a tool message AND a user message (because image is present)
317+
expect(openAiMessages).toHaveLength(2)
318+
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool")
319+
expect(openAiMessages[1].role).toBe("user")
320+
})
321+
322+
it("should create separate user message when mergeToolResultText is false", () => {
323+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
324+
{
325+
role: "user",
326+
content: [
327+
{
328+
type: "tool_result",
329+
tool_use_id: "tool-123",
330+
content: "Tool result content",
331+
},
332+
{
333+
type: "text",
334+
text: "<environment_details>\nSome context\n</environment_details>",
335+
},
336+
],
337+
},
338+
]
339+
340+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: false })
341+
342+
// Should produce a tool message AND a separate user message (default behavior)
343+
expect(openAiMessages).toHaveLength(2)
344+
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool")
345+
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe(
346+
"Tool result content",
347+
)
348+
expect(openAiMessages[1].role).toBe("user")
349+
})
350+
351+
it("should work with normalizeToolCallId when mergeToolResultText is true", () => {
352+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
353+
{
354+
role: "user",
355+
content: [
356+
{
357+
type: "tool_result",
358+
tool_use_id: "call_5019f900a247472bacde0b82",
359+
content: "Tool result content",
360+
},
361+
{
362+
type: "text",
363+
text: "<environment_details>Context</environment_details>",
364+
},
365+
],
366+
},
367+
]
368+
369+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, {
370+
mergeToolResultText: true,
371+
normalizeToolCallId: normalizeMistralToolCallId,
372+
})
373+
374+
// Should merge AND normalize the ID
375+
expect(openAiMessages).toHaveLength(1)
376+
const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
377+
expect(toolMessage.role).toBe("tool")
378+
expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82"))
379+
expect(toolMessage.content).toBe(
380+
"Tool result content\n\n<environment_details>Context</environment_details>",
381+
)
382+
})
383+
384+
it("should handle user messages with only text content (no tool results)", () => {
385+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
386+
{
387+
role: "user",
388+
content: [
389+
{
390+
type: "text",
391+
text: "Hello, how are you?",
392+
},
393+
],
394+
},
395+
]
396+
397+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })
398+
399+
// Should produce a normal user message
400+
expect(openAiMessages).toHaveLength(1)
401+
expect(openAiMessages[0].role).toBe("user")
402+
})
403+
})
227404
})

src/api/transform/openai-format.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export interface ConvertToOpenAiMessagesOptions {
1111
* This allows callers to declare provider-specific ID format requirements.
1212
*/
1313
normalizeToolCallId?: (id: string) => string
14+
/**
15+
* If true, merge text content after tool_results into the last tool message
16+
* instead of creating a separate user message. This is critical for providers
17+
* with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where
18+
* a user message after tool results causes the model to drop all previous
19+
* reasoning_content. Default is false for backward compatibility.
20+
*/
21+
mergeToolResultText?: boolean
1422
}
1523

1624
export function convertToOpenAiMessages(
@@ -95,18 +103,40 @@ export function convertToOpenAiMessages(
95103

96104
// Process non-tool messages
97105
if (nonToolMessages.length > 0) {
98-
openAiMessages.push({
99-
role: "user",
100-
content: nonToolMessages.map((part) => {
101-
if (part.type === "image") {
102-
return {
103-
type: "image_url",
104-
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
106+
// Check if we should merge text into the last tool message
107+
// This is critical for reasoning/thinking models where a user message
108+
// after tool results causes the model to drop all previous reasoning_content
109+
const hasOnlyTextContent = nonToolMessages.every((part) => part.type === "text")
110+
const hasToolMessages = toolMessages.length > 0
111+
const shouldMergeIntoToolMessage =
112+
options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent
113+
114+
if (shouldMergeIntoToolMessage) {
115+
// Merge text content into the last tool message
116+
const lastToolMessage = openAiMessages[
117+
openAiMessages.length - 1
118+
] as OpenAI.Chat.ChatCompletionToolMessageParam
119+
if (lastToolMessage?.role === "tool") {
120+
const additionalText = nonToolMessages
121+
.map((part) => (part as Anthropic.TextBlockParam).text)
122+
.join("\n")
123+
lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}`
124+
}
125+
} else {
126+
// Standard behavior: add user message with text/image content
127+
openAiMessages.push({
128+
role: "user",
129+
content: nonToolMessages.map((part) => {
130+
if (part.type === "image") {
131+
return {
132+
type: "image_url",
133+
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
134+
}
105135
}
106-
}
107-
return { type: "text", text: part.text }
108-
}),
109-
})
136+
return { type: "text", text: part.text }
137+
}),
138+
})
139+
}
110140
}
111141
} else if (anthropicMessage.role === "assistant") {
112142
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{

0 commit comments

Comments
 (0)