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
72 changes: 39 additions & 33 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

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
// 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
}

// FIXME: remove tool use blocks altogether

Expand Down
200 changes: 200 additions & 0 deletions src/core/task/__tests__/task-tool-history.spec.ts
Original file line number Diff line number Diff line change
@@ -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"}',
})
})
})
})
78 changes: 78 additions & 0 deletions src/core/task/__tests__/task-xml-protocol-regression.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
}
})
})
Loading