Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ export class NativeToolCallParser {
break

case "write_to_file":
if (partialArgs.path || partialArgs.content) {
if (partialArgs.path !== undefined && partialArgs.content !== undefined) {
nativeArgs = {
path: partialArgs.path,
content: partialArgs.content,
Expand Down
101 changes: 101 additions & 0 deletions src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,107 @@ describe("NativeToolCallParser", () => {
})
})

describe("write_to_file partial streaming", () => {
it("should not set nativeArgs when only path is present (content missing)", () => {
const id = "toolu_wtf_partial_1"
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")

// Simulate partial chunk with only path, no content yet
const result = NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ path: "test.txt" }))

// nativeArgs should NOT be set when content is missing
expect(result).not.toBeNull()
if (result) {
expect(result.nativeArgs).toBeUndefined()
// params should still have path for UI display
expect(result.params.path).toBe("test.txt")
}
})

it("should set nativeArgs when both path and content are present", () => {
const id = "toolu_wtf_partial_2"
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")

const result = NativeToolCallParser.processStreamingChunk(
id,
JSON.stringify({ path: "test.txt", content: "hello world" }),
)

expect(result).not.toBeNull()
if (result) {
expect(result.nativeArgs).toBeDefined()
const nativeArgs = result.nativeArgs as { path: string; content: string }
expect(nativeArgs.path).toBe("test.txt")
expect(nativeArgs.content).toBe("hello world")
}
})
})

describe("write_to_file parseToolCall (complete)", () => {
it("should return null when content is missing from complete tool call", () => {
const toolCall = {
id: "toolu_wtf_complete_1",
name: "write_to_file" as const,
arguments: JSON.stringify({ path: "test.txt" }),
}

// parseToolCall throws internally and returns null for invalid args
const result = NativeToolCallParser.parseToolCall(toolCall)
expect(result).toBeNull()
})

it("should parse correctly when both path and content are present", () => {
const toolCall = {
id: "toolu_wtf_complete_2",
name: "write_to_file" as const,
arguments: JSON.stringify({ path: "test.txt", content: "hello world" }),
}

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; content: string }
expect(nativeArgs.path).toBe("test.txt")
expect(nativeArgs.content).toBe("hello world")
}
})
})

describe("write_to_file finalization fallback", () => {
it("should return null when finalized with missing content", () => {
const id = "toolu_wtf_finalize_1"
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")

// Stream only path, no content
NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ path: "test.txt" }))

// Finalization should fail (return null) because content is missing
const result = NativeToolCallParser.finalizeStreamingToolCall(id)
expect(result).toBeNull()
})

it("should finalize successfully when both path and content are present", () => {
const id = "toolu_wtf_finalize_2"
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")

NativeToolCallParser.processStreamingChunk(
id,
JSON.stringify({ path: "test.txt", content: "file content" }),
)

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; content: string }
expect(nativeArgs.path).toBe("test.txt")
expect(nativeArgs.content).toBe("file content")
}
})
})

describe("finalizeStreamingToolCall", () => {
describe("read_file tool", () => {
it("should parse read_file args on finalize", () => {
Expand Down
16 changes: 14 additions & 2 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2967,6 +2967,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const existingToolUse = this.assistantMessageContent[toolUseIndex]
if (existingToolUse && existingToolUse.type === "tool_use") {
existingToolUse.partial = false
// Clear stale nativeArgs from partial parsing so the safety check
// in presentAssistantMessage (!block.nativeArgs) properly catches
// this as an invalid tool call. Without this, incomplete partial
// nativeArgs (e.g., path set but content undefined) would bypass
// the check and cause tools to execute with missing parameters.
existingToolUse.nativeArgs = undefined
// Ensure it has the ID for native protocol
;(existingToolUse as any).id = event.id
}
Expand Down Expand Up @@ -3350,11 +3356,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
presentAssistantMessage(this)
} else if (toolUseIndex !== undefined) {
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
// We still need to mark the tool as non-partial so it gets executed
// The tool's validation will catch any missing required parameters
// We still need to mark the tool as non-partial so it gets presented.
// The presentAssistantMessage safety check will catch missing nativeArgs.
const existingToolUse = this.assistantMessageContent[toolUseIndex]
if (existingToolUse && existingToolUse.type === "tool_use") {
existingToolUse.partial = false
// Clear stale nativeArgs from partial parsing so the safety check
// in presentAssistantMessage (!block.nativeArgs) properly catches
// this as an invalid tool call. Without this, incomplete partial
// nativeArgs (e.g., path set but content undefined) would bypass
// the check and cause tools to execute with missing parameters.
existingToolUse.nativeArgs = undefined
// Ensure it has the ID for native protocol
;(existingToolUse as any).id = event.id
}
Expand Down
Loading