Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion src/api/providers/roo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
model,
max_tokens,
temperature,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
// Enable mergeToolResultText to merge environment_details and other text content
// after tool_results into the last tool message. This prevents reasoning/thinking
// models from dropping reasoning_content when they see a user message after tool results.
messages: [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages, { mergeToolResultText: true }),
],
stream: true,
stream_options: { include_usage: true },
...(reasoning && { reasoning }),
Expand Down
177 changes: 177 additions & 0 deletions src/api/transform/__tests__/openai-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,181 @@ describe("convertToOpenAiMessages", () => {
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123")
})

describe("mergeToolResultText option", () => {
it("should merge text content into last tool message when mergeToolResultText is true", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-123",
content: "Tool result content",
},
{
type: "text",
text: "<environment_details>\nSome context\n</environment_details>",
},
],
},
]

const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })

// Should produce only one tool message with merged content
expect(openAiMessages).toHaveLength(1)
const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMessage.role).toBe("tool")
expect(toolMessage.tool_call_id).toBe("tool-123")
expect(toolMessage.content).toBe(
"Tool result content\n\n<environment_details>\nSome context\n</environment_details>",
)
})

it("should merge text into last tool message when multiple tool results exist", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: "First result",
},
{
type: "tool_result",
tool_use_id: "call_2",
content: "Second result",
},
{
type: "text",
text: "<environment_details>Context</environment_details>",
},
],
},
]

const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })

// Should produce two tool messages, with text merged into the last one
expect(openAiMessages).toHaveLength(2)
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe("First result")
expect((openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe(
"Second result\n\n<environment_details>Context</environment_details>",
)
})

it("should NOT merge text when images are present (fall back to user message)", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-123",
content: "Tool result content",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "base64data",
},
},
],
},
]

const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })

// Should produce a tool message AND a user message (because image is present)
expect(openAiMessages).toHaveLength(2)
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool")
expect(openAiMessages[1].role).toBe("user")
})

it("should create separate user message when mergeToolResultText is false", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-123",
content: "Tool result content",
},
{
type: "text",
text: "<environment_details>\nSome context\n</environment_details>",
},
],
},
]

const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: false })

// Should produce a tool message AND a separate user message (default behavior)
expect(openAiMessages).toHaveLength(2)
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool")
expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe(
"Tool result content",
)
expect(openAiMessages[1].role).toBe("user")
})

it("should work with normalizeToolCallId when mergeToolResultText is true", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_5019f900a247472bacde0b82",
content: "Tool result content",
},
{
type: "text",
text: "<environment_details>Context</environment_details>",
},
],
},
]

const openAiMessages = convertToOpenAiMessages(anthropicMessages, {
mergeToolResultText: true,
normalizeToolCallId: normalizeMistralToolCallId,
})

// Should merge AND normalize the ID
expect(openAiMessages).toHaveLength(1)
const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
expect(toolMessage.role).toBe("tool")
expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82"))
expect(toolMessage.content).toBe(
"Tool result content\n\n<environment_details>Context</environment_details>",
)
})

it("should handle user messages with only text content (no tool results)", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Hello, how are you?",
},
],
},
]

const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true })

// Should produce a normal user message
expect(openAiMessages).toHaveLength(1)
expect(openAiMessages[0].role).toBe("user")
})
})
})
52 changes: 41 additions & 11 deletions src/api/transform/openai-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export interface ConvertToOpenAiMessagesOptions {
* This allows callers to declare provider-specific ID format requirements.
*/
normalizeToolCallId?: (id: string) => string
/**
* If true, merge text content after tool_results into the last tool message
* instead of creating a separate user message. This is critical for providers
* with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where
* a user message after tool results causes the model to drop all previous
* reasoning_content. Default is false for backward compatibility.
*/
mergeToolResultText?: boolean
}

export function convertToOpenAiMessages(
Expand Down Expand Up @@ -95,18 +103,40 @@ export function convertToOpenAiMessages(

// Process non-tool messages
if (nonToolMessages.length > 0) {
openAiMessages.push({
role: "user",
content: nonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
// Check if we should merge text into the last tool message
// This is critical for reasoning/thinking models where a user message
// after tool results causes the model to drop all previous reasoning_content
const hasOnlyTextContent = nonToolMessages.every((part) => part.type === "text")
const hasToolMessages = toolMessages.length > 0
const shouldMergeIntoToolMessage =
options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent

if (shouldMergeIntoToolMessage) {
// Merge text content into the last tool message
const lastToolMessage = openAiMessages[
openAiMessages.length - 1
] as OpenAI.Chat.ChatCompletionToolMessageParam
if (lastToolMessage?.role === "tool") {
const additionalText = nonToolMessages
.map((part) => (part as Anthropic.TextBlockParam).text)
.join("\n")
lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}`
}
} else {
// Standard behavior: add user message with text/image content
openAiMessages.push({
role: "user",
content: nonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
}
}
}
return { type: "text", text: part.text }
}),
})
return { type: "text", text: part.text }
}),
})
}
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
Expand Down
Loading