Skip to content
Open
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
24 changes: 22 additions & 2 deletions src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,17 @@ export class NativeToolCallParser {
break

case "apply_diff":
if (partialArgs.path !== undefined || partialArgs.diff !== undefined) {
// Multi-file format (from multi_apply_diff schema)
if (partialArgs.files && Array.isArray(partialArgs.files)) {
nativeArgs = {
files: partialArgs.files.map((f: any) => ({
path: f.path,
diff: f.diff,
})),
}
}
// Single-file format (from apply_diff schema)
else if (partialArgs.path !== undefined || partialArgs.diff !== undefined) {
nativeArgs = {
path: partialArgs.path,
diff: partialArgs.diff,
Expand Down Expand Up @@ -633,7 +643,17 @@ export class NativeToolCallParser {
break

case "apply_diff":
if (args.path !== undefined && args.diff !== undefined) {
// Multi-file format (from multi_apply_diff schema)
if (args.files && Array.isArray(args.files)) {
nativeArgs = {
files: args.files.map((f: any) => ({
path: f.path,
diff: f.diff,
})),
} as NativeArgsFor<TName>
}
// Single-file format (from apply_diff schema)
else if (args.path !== undefined && args.diff !== undefined) {
nativeArgs = {
path: args.path,
diff: args.diff,
Expand Down
201 changes: 201 additions & 0 deletions src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,5 +237,206 @@ describe("NativeToolCallParser", () => {
}
})
})

describe("parseToolCall", () => {
describe("apply_diff tool", () => {
it("should handle single-file format (path and diff)", () => {
const toolCall = {
id: "toolu_123",
name: "apply_diff" as const,
arguments: JSON.stringify({
path: "src/test.ts",
diff: "<<<<<<< SEARCH\nold code\n=======\nnew code\n>>>>>>> REPLACE",
}),
}

const result = NativeToolCallParser.parseToolCall(toolCall)

expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
expect(result.nativeArgs).toBeDefined()
const nativeArgs = result.nativeArgs as { path: string; diff: string }
expect(nativeArgs.path).toBe("src/test.ts")
expect(nativeArgs.diff).toContain("<<<<<<< SEARCH")
expect(nativeArgs.diff).toContain(">>>>>>> REPLACE")
}
})

it("should handle multi-file format (files array)", () => {
const toolCall = {
id: "toolu_456",
name: "apply_diff" as const,
arguments: JSON.stringify({
files: [
{
path: "src/file1.ts",
diff: "<<<<<<< SEARCH\nold code 1\n=======\nnew code 1\n>>>>>>> REPLACE",
},
{
path: "src/file2.ts",
diff: "<<<<<<< SEARCH\nold code 2\n=======\nnew code 2\n>>>>>>> REPLACE",
},
],
}),
}

const result = NativeToolCallParser.parseToolCall(toolCall)

expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
expect(result.nativeArgs).toBeDefined()
const nativeArgs = result.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(2)
expect(nativeArgs.files[0].path).toBe("src/file1.ts")
expect(nativeArgs.files[0].diff).toContain("old code 1")
expect(nativeArgs.files[1].path).toBe("src/file2.ts")
expect(nativeArgs.files[1].diff).toContain("old code 2")
}
})

it("should handle multi-file format with single file", () => {
const toolCall = {
id: "toolu_789",
name: "apply_diff" as const,
arguments: JSON.stringify({
files: [
{
path: "src/single.ts",
diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
},
],
}),
}

const result = NativeToolCallParser.parseToolCall(toolCall)

expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
const nativeArgs = result.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(1)
expect(nativeArgs.files[0].path).toBe("src/single.ts")
}
})
})
})

describe("processStreamingChunk", () => {
describe("apply_diff tool", () => {
it("should handle single-file format during streaming", () => {
const id = "toolu_streaming_apply_diff_single"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")

const fullArgs = JSON.stringify({
path: "streaming/test.ts",
diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
})

const result = NativeToolCallParser.processStreamingChunk(id, fullArgs)

expect(result).not.toBeNull()
expect(result?.nativeArgs).toBeDefined()
const nativeArgs = result?.nativeArgs as { path: string; diff: string }
expect(nativeArgs.path).toBe("streaming/test.ts")
expect(nativeArgs.diff).toContain("<<<<<<< SEARCH")
})

it("should handle multi-file format during streaming", () => {
const id = "toolu_streaming_apply_diff_multi"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")

const fullArgs = JSON.stringify({
files: [
{
path: "streaming/file1.ts",
diff: "<<<<<<< SEARCH\nold1\n=======\nnew1\n>>>>>>> REPLACE",
},
{
path: "streaming/file2.ts",
diff: "<<<<<<< SEARCH\nold2\n=======\nnew2\n>>>>>>> REPLACE",
},
],
})

const result = NativeToolCallParser.processStreamingChunk(id, fullArgs)

expect(result).not.toBeNull()
expect(result?.nativeArgs).toBeDefined()
const nativeArgs = result?.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(2)
expect(nativeArgs.files[0].path).toBe("streaming/file1.ts")
expect(nativeArgs.files[1].path).toBe("streaming/file2.ts")
})
})
})

describe("finalizeStreamingToolCall", () => {
describe("apply_diff tool", () => {
it("should finalize single-file format correctly", () => {
const id = "toolu_finalize_apply_diff_single"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")

NativeToolCallParser.processStreamingChunk(
id,
JSON.stringify({
path: "finalized/single.ts",
diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
}),
)

const result = NativeToolCallParser.finalizeStreamingToolCall(id)

expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
const nativeArgs = result.nativeArgs as { path: string; diff: string }
expect(nativeArgs.path).toBe("finalized/single.ts")
expect(nativeArgs.diff).toContain("<<<<<<< SEARCH")
}
})

it("should finalize multi-file format correctly", () => {
const id = "toolu_finalize_apply_diff_multi"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")

NativeToolCallParser.processStreamingChunk(
id,
JSON.stringify({
files: [
{
path: "finalized/file1.ts",
diff: "<<<<<<< SEARCH\nold1\n=======\nnew1\n>>>>>>> REPLACE",
},
{
path: "finalized/file2.ts",
diff: "<<<<<<< SEARCH\nold2\n=======\nnew2\n>>>>>>> REPLACE",
},
],
}),
)

const result = NativeToolCallParser.finalizeStreamingToolCall(id)

expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
const nativeArgs = result.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(2)
expect(nativeArgs.files[0].path).toBe("finalized/file1.ts")
expect(nativeArgs.files[1].path).toBe("finalized/file2.ts")
}
})
})
})
})
})
58 changes: 48 additions & 10 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,24 @@ export async function presentAssistantMessage(cline: Task) {
case "write_to_file":
return `[${block.name} for '${block.params.path}']`
case "apply_diff":
// Handle both legacy format and new multi-file format
if (block.params.path) {
// Handle native multi-file format (from multi_apply_diff schema)
if (block.nativeArgs?.files && Array.isArray(block.nativeArgs.files)) {
const files = block.nativeArgs.files
const firstPath = files[0]?.path
if (firstPath) {
if (files.length > 1) {
return `[${block.name} for '${firstPath}' and ${files.length - 1} more file${files.length > 2 ? "s" : ""}]`
} else {
return `[${block.name} for '${firstPath}']`
}
}
}
// Handle native single-file format
else if (block.nativeArgs?.path) {
return `[${block.name} for '${block.nativeArgs.path}']`
}
// Handle XML legacy format
else if (block.params.path) {
return `[${block.name} for '${block.params.path}']`
} else if (block.params.args) {
// Try to extract first file path from args for display
Expand Down Expand Up @@ -722,6 +738,7 @@ export async function presentAssistantMessage(cline: Task) {
block.params,
stateExperiments,
includedTools,
block.nativeArgs,
)
} catch (error) {
cline.consecutiveMistakeCount++
Expand Down Expand Up @@ -817,17 +834,38 @@ export async function presentAssistantMessage(cline: Task) {
// Check if this tool call came from native protocol by checking for ID
// Native calls always have IDs, XML calls never do
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
// For native protocol, route based on nativeArgs format:
// - nativeArgs.files (array) -> multi-file tool (from multi_apply_diff schema)
// - nativeArgs.path (string) -> single-file tool (from apply_diff schema)
const nativeArgs = block.nativeArgs as
| { files: Array<{ path: string; diff: string }> }
| { path: string; diff: string }
| undefined

if (nativeArgs && "files" in nativeArgs && Array.isArray(nativeArgs.files)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In native mode, multi-file apply_diff routes via nativeArgs.files, but the mode fileRegex restrictions are currently enforced via validateToolUse(block.params). Since params may not include a single path (and validateToolUse does not parse the JSON-encoded params.files), this can bypass file restrictions for multi-file native apply_diff. Consider validating each entry in nativeArgs.files (or passing a normalized {path/args} into validation) before dispatching to MultiApplyDiffTool.

Fix it with Roo Code or mention @roomote and request a fix.

// Multi-file format: use MultiApplyDiffTool
await applyDiffTool(
cline,
block,
askApproval,
handleError,
pushToolResult,
removeClosingTag,
)
} else {
// Single-file format: use ApplyDiffTool
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
}
break
}

// Get the provider and state to check experiment settings
// For XML protocol, check experiment settings to determine routing
const provider = cline.providerRef.deref()
let isMultiFileApplyDiffEnabled = false

Expand Down
12 changes: 12 additions & 0 deletions src/core/prompts/tools/native-tools/apply_diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type OpenAI from "openai"
import { multi_apply_diff } from "./multi_apply_diff"

const APPLY_DIFF_DESCRIPTION = `Apply precise, targeted modifications to an existing file using one or more search/replace blocks. This tool is for surgical edits only; the 'SEARCH' block must exactly match the existing content, including whitespace and indentation. To make multiple targeted changes, provide multiple SEARCH/REPLACE blocks in the 'diff' parameter. Use the 'read_file' tool first if you are not confident in the exact content to search for.`

Expand Down Expand Up @@ -33,3 +34,14 @@ export const apply_diff = {
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

/**
* Creates the apply_diff tool definition, selecting between single-file and multi-file
* schemas based on whether the multi-file experiment is enabled.
*
* @param multiFileEnabled - Whether to use the multi-file schema (default: false)
* @returns Native tool definition for apply_diff
*/
export function createApplyDiffTool(multiFileEnabled: boolean = false): OpenAI.Chat.ChatCompletionTool {
return multiFileEnabled ? multi_apply_diff : apply_diff
}
10 changes: 7 additions & 3 deletions src/core/prompts/tools/native-tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type OpenAI from "openai"
import accessMcpResource from "./access_mcp_resource"
import { apply_diff } from "./apply_diff"
import { createApplyDiffTool } from "./apply_diff"
import applyPatch from "./apply_patch"
import askFollowupQuestion from "./ask_followup_question"
import attemptCompletion from "./attempt_completion"
Expand All @@ -27,12 +27,16 @@ export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./c
* Get native tools array, optionally customizing based on settings.
*
* @param partialReadsEnabled - Whether to include line_ranges support in read_file tool (default: true)
* @param multiFileApplyDiffEnabled - Whether to use multi-file apply_diff schema (default: false)
* @returns Array of native tool definitions
*/
export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool[] {
export function getNativeTools(
partialReadsEnabled: boolean = true,
multiFileApplyDiffEnabled: boolean = false,
): OpenAI.Chat.ChatCompletionTool[] {
return [
accessMcpResource,
apply_diff,
createApplyDiffTool(multiFileApplyDiffEnabled),
applyPatch,
askFollowupQuestion,
attemptCompletion,
Expand Down
Loading
Loading