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
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}

// Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
const existingResult = mockTask.userMessageContent.find(
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
return false
}
mockTask.userMessageContent.push(toolResult)
return true
})
})

describe("Custom tool usage recording", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}

// Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
const existingResult = mockTask.userMessageContent.find(
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
return false
}
mockTask.userMessageContent.push(toolResult)
return true
})
})

it("should preserve images in tool_result for native protocol", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}

// Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
const existingResult = mockTask.userMessageContent.find(
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
return false
}
mockTask.userMessageContent.push(toolResult)
return true
})
})

it("should return error for unknown tool in native protocol", async () => {
Expand Down
32 changes: 16 additions & 16 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,12 @@ export async function presentAssistantMessage(cline: Task) {
: `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.`

if (toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
}
break
}
Expand All @@ -130,12 +130,12 @@ export async function presentAssistantMessage(cline: Task) {
const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`

if (toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
}
break
}
Expand Down Expand Up @@ -167,11 +167,11 @@ export async function presentAssistantMessage(cline: Task) {
}

if (toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: resultContent,
} as Anthropic.ToolResultBlockParam)
})

if (imageBlocks.length > 0) {
cline.userMessageContent.push(...imageBlocks)
Expand Down Expand Up @@ -446,12 +446,12 @@ export async function presentAssistantMessage(cline: Task) {

if (toolCallId) {
// Native protocol: MUST send tool_result for every tool_use
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
// XML protocol: send as text
cline.userMessageContent.push({
Expand All @@ -471,12 +471,12 @@ export async function presentAssistantMessage(cline: Task) {

if (toolCallId) {
// Native protocol: MUST send tool_result for every tool_use
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
// XML protocol: send as text
cline.userMessageContent.push({
Expand Down Expand Up @@ -530,11 +530,11 @@ export async function presentAssistantMessage(cline: Task) {
}

// Add tool_result with text content only
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: resultContent,
} as Anthropic.ToolResultBlockParam)
})

// Add image blocks separately after tool_result
if (imageBlocks.length > 0) {
Expand Down Expand Up @@ -735,12 +735,12 @@ export async function presentAssistantMessage(cline: Task) {

if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
// For native protocol, push tool_result directly without setting didAlreadyUseTool
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: typeof errorContent === "string" ? errorContent : "(validation error)",
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
// For XML protocol, use the standard pushToolResult
pushToolResult(errorContent)
Expand Down Expand Up @@ -1110,12 +1110,12 @@ export async function presentAssistantMessage(cline: Task) {
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
cline.userMessageContent.push({
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: formatResponse.toolError(errorMessage, toolProtocol),
is_error: true,
} as Anthropic.ToolResultBlockParam)
})
} else {
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
}
Expand Down
22 changes: 22 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,28 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
presentAssistantMessageHasPendingUpdates = false
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = []
userMessageContentReady = false

/**
* Push a tool_result block to userMessageContent, preventing duplicates.
* This is critical for native tool protocol where duplicate tool_use_ids cause API errors.
*
* @param toolResult - The tool_result block to add
* @returns true if added, false if duplicate was skipped
*/
public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean {
const existingResult = this.userMessageContent.find(
(block): block is Anthropic.ToolResultBlockParam =>
block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
console.warn(
`[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`,
)
return false
}
this.userMessageContent.push(toolResult)
return true
}
didRejectTool = false
didAlreadyUseTool = false
didToolFailInCurrentTurn = false
Expand Down
Loading
Loading