fix: preserve tool blocks for native protocol in conversation history (#9319)

This commit is contained in:
Daniel 2025-11-17 14:07:17 -05:00 committed by GitHub
parent 0e51a1ab9b
commit fc19e7620b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 316 additions and 32 deletions

View file

@ -1415,8 +1415,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// even if it goes out of sync with cline messages.
let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory()
// v2.0 xml tags refactor caveat: since we don't use tools anymore, we need to replace all tool use blocks with a text block since the API disallows conversations with tool uses and no tool schema
// Now also protocol-aware: format according to current protocol setting
// v2.0 xml tags refactor caveat: since we don't use tools anymore for XML protocol,
// we need to replace all tool use blocks with a text block since the API disallows
// conversations with tool uses and no tool schema.
// For native protocol, we preserve tool_use and tool_result blocks as they're expected by the API.
const state = await this.providerRef.deref()?.getState()
const protocol = resolveToolProtocol(
this.apiConfiguration,
@ -1426,37 +1428,41 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
)
const useNative = isNativeProtocol(protocol)
const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
if (Array.isArray(message.content)) {
const newContent = message.content.map((block) => {
if (block.type === "tool_use") {
// Format tool invocation based on protocol
const params = block.input as Record<string, any>
const formattedText = formatToolInvocation(block.name, params, protocol)
// Only convert tool blocks to text for XML protocol
// For native protocol, the API expects proper tool_use/tool_result structure
if (!useNative) {
const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
if (Array.isArray(message.content)) {
const newContent = message.content.map((block) => {
if (block.type === "tool_use") {
// Format tool invocation based on protocol
const params = block.input as Record<string, any>
const formattedText = formatToolInvocation(block.name, params, protocol)
return {
type: "text",
text: formattedText,
} as Anthropic.Messages.TextBlockParam
} else if (block.type === "tool_result") {
// Convert block.content to text block array, removing images
const contentAsTextBlocks = Array.isArray(block.content)
? block.content.filter((item) => item.type === "text")
: [{ type: "text", text: block.content }]
const textContent = contentAsTextBlocks.map((item) => item.text).join("\n\n")
const toolName = findToolName(block.tool_use_id, existingApiConversationHistory)
return {
type: "text",
text: `[${toolName} Result]\n\n${textContent}`,
} as Anthropic.Messages.TextBlockParam
}
return block
})
return { ...message, content: newContent }
}
return message
})
existingApiConversationHistory = conversationWithoutToolBlocks
return {
type: "text",
text: formattedText,
} as Anthropic.Messages.TextBlockParam
} else if (block.type === "tool_result") {
// Convert block.content to text block array, removing images
const contentAsTextBlocks = Array.isArray(block.content)
? block.content.filter((item) => item.type === "text")
: [{ type: "text", text: block.content }]
const textContent = contentAsTextBlocks.map((item) => item.text).join("\n\n")
const toolName = findToolName(block.tool_use_id, existingApiConversationHistory)
return {
type: "text",
text: `[${toolName} Result]\n\n${textContent}`,
} as Anthropic.Messages.TextBlockParam
}
return block
})
return { ...message, content: newContent }
}
return message
})
existingApiConversationHistory = conversationWithoutToolBlocks
}
// FIXME: remove tool use blocks altogether

View file

@ -0,0 +1,200 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import { TOOL_PROTOCOL } from "@roo-code/types"
import { resolveToolProtocol } from "../../../utils/resolveToolProtocol"
describe("Task Tool History Handling", () => {
describe("resumeTaskFromHistory tool block preservation", () => {
it("should preserve tool_use and tool_result blocks for native protocol", () => {
// Mock API conversation history with tool blocks
const apiHistory: any[] = [
{
role: "user",
content: "Read the file config.json",
ts: Date.now(),
},
{
role: "assistant",
content: [
{
type: "text",
text: "I'll read that file for you.",
},
{
type: "tool_use",
id: "toolu_123",
name: "read_file",
input: { path: "config.json" },
},
],
ts: Date.now(),
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_123",
content: '{"setting": "value"}',
},
],
ts: Date.now(),
},
]
// Simulate the protocol check
const mockApiConfiguration = { apiProvider: "roo" as const }
const mockModelInfo = { supportsNativeTools: true }
const mockExperiments = {}
const protocol = TOOL_PROTOCOL.NATIVE
// Test the logic that should NOT convert tool blocks for native protocol
const useNative = protocol === TOOL_PROTOCOL.NATIVE
if (!useNative) {
// This block should NOT execute for native protocol
throw new Error("Should not convert tool blocks for native protocol")
}
// Verify tool blocks are preserved
const assistantMessage = apiHistory[1]
const userMessage = apiHistory[2]
expect(assistantMessage.content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "tool_use",
id: "toolu_123",
name: "read_file",
}),
]),
)
expect(userMessage.content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "tool_result",
tool_use_id: "toolu_123",
}),
]),
)
})
it("should convert tool blocks to text for XML protocol", () => {
// Mock API conversation history with tool blocks
const apiHistory: any[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_123",
name: "read_file",
input: { path: "config.json" },
},
],
ts: Date.now(),
},
]
// Simulate XML protocol - tool blocks should be converted to text
const protocol = "xml"
const useNative = false // XML protocol is not native
// For XML protocol, we should convert tool blocks
if (!useNative) {
const conversationWithoutToolBlocks = apiHistory.map((message) => {
if (Array.isArray(message.content)) {
const newContent = message.content.map((block: any) => {
if (block.type === "tool_use") {
return {
type: "text",
text: `<read_file>\n<path>\nconfig.json\n</path>\n</read_file>`,
}
}
return block
})
return { ...message, content: newContent }
}
return message
})
// Verify tool blocks were converted to text
expect(conversationWithoutToolBlocks[0].content[0].type).toBe("text")
expect(conversationWithoutToolBlocks[0].content[0].text).toContain("<read_file>")
}
})
})
describe("convertToOpenAiMessages format", () => {
it("should properly convert tool_use to tool_calls format", () => {
const anthropicMessage: Anthropic.Messages.MessageParam = {
role: "assistant",
content: [
{
type: "text",
text: "I'll read that file.",
},
{
type: "tool_use",
id: "toolu_123",
name: "read_file",
input: { path: "config.json" },
},
],
}
// Simulate what convertToOpenAiMessages does
const toolUseBlocks = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_use")
const tool_calls = toolUseBlocks.map((toolMessage) => ({
id: toolMessage.id,
type: "function" as const,
function: {
name: toolMessage.name,
arguments: JSON.stringify(toolMessage.input),
},
}))
expect(tool_calls).toHaveLength(1)
expect(tool_calls[0]).toEqual({
id: "toolu_123",
type: "function",
function: {
name: "read_file",
arguments: '{"path":"config.json"}',
},
})
})
it("should properly convert tool_result to tool role messages", () => {
const anthropicMessage: Anthropic.Messages.MessageParam = {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_123",
content: '{"setting": "value"}',
},
],
}
// Simulate what convertToOpenAiMessages does
const toolMessages = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_result")
const openAiToolMessages = toolMessages.map((toolMessage) => ({
role: "tool" as const,
tool_call_id: toolMessage.tool_use_id,
content: typeof toolMessage.content === "string" ? toolMessage.content : toolMessage.content[0].text,
}))
expect(openAiToolMessages).toHaveLength(1)
expect(openAiToolMessages[0]).toEqual({
role: "tool",
tool_call_id: "toolu_123",
content: '{"setting": "value"}',
})
})
})
})

View file

@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest"
import { formatToolInvocation } from "../../tools/helpers/toolResultFormatting"
/**
* Regression tests to ensure XML protocol behavior remains unchanged
* after adding native protocol support.
*/
describe("XML Protocol Regression Tests", () => {
it("should format tool invocations as XML tags for xml protocol", () => {
const result = formatToolInvocation(
"read_file",
{ path: "config.json", start_line: "1", end_line: "10" },
"xml",
)
expect(result).toContain("<read_file>")
expect(result).toContain("<path>")
expect(result).toContain("config.json")
expect(result).toContain("</path>")
expect(result).toContain("<start_line>")
expect(result).toContain("1")
expect(result).toContain("</start_line>")
expect(result).toContain("</read_file>")
})
it("should handle complex nested structures in XML format", () => {
const result = formatToolInvocation(
"execute_command",
{
command: "npm install",
cwd: "/home/user/project",
},
"xml",
)
expect(result).toContain("<execute_command>")
expect(result).toContain("<command>")
expect(result).toContain("npm install")
expect(result).toContain("</command>")
expect(result).toContain("<cwd>")
expect(result).toContain("/home/user/project")
expect(result).toContain("</cwd>")
expect(result).toContain("</execute_command>")
})
it("should handle empty parameters correctly in XML format", () => {
const result = formatToolInvocation("list_files", {}, "xml")
expect(result).toBe("<list_files>\n\n</list_files>")
})
it("should preserve XML format for tool results in conversation history", () => {
// Simulate what happens in resumeTaskFromHistory for XML protocol
const useNative = false // XML protocol
const mockToolUse = {
type: "tool_use",
id: "toolu_123",
name: "read_file",
input: { path: "test.ts" },
}
if (!useNative) {
// This is the conversion logic that should happen for XML
const converted = {
type: "text",
text: formatToolInvocation(mockToolUse.name, mockToolUse.input as Record<string, any>, "xml"),
}
expect(converted.type).toBe("text")
expect(converted.text).toContain("<read_file>")
expect(converted.text).toContain("<path>")
expect(converted.text).toContain("test.ts")
} else {
throw new Error("Should not reach here for XML protocol")
}
})
})