diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9f..d89a8107c1 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -46,6 +46,7 @@ export const toolNames = [ "skill", "generate_image", "custom_tool", + "invalid_tool_call", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts new file mode 100644 index 0000000000..a5e16fa5f5 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -0,0 +1,310 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts + +import type { Anthropic } from "@anthropic-ai/sdk" +import { describe, it, expect, beforeEach, vi } from "vitest" +import { presentAssistantMessage } from "../presentAssistantMessage" +import { validateToolUse } from "../../tools/validateToolUse" +import { getModeBySlug } from "../../../shared/modes" +import type { Task } from "../../task/Task" + +vi.mock("../../task/Task") +vi.mock("../../../shared/modes", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getModeBySlug: vi.fn(actual.getModeBySlug), + } +}) +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn((toolName: string) => + ["read_file", "write_to_file", "ask_followup_question", "attempt_completion", "use_mcp_tool"].includes( + toolName, + ), + ), +})) + +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + has: vi.fn(() => false), + get: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureEvent: vi.fn(), + }, + }, +})) + +import { TelemetryService } from "@roo-code/telemetry" + +interface MockTask { + taskId: string + instanceId: string + abort: boolean + presentAssistantMessageLocked: boolean + presentAssistantMessageHasPendingUpdates: boolean + currentStreamingContentIndex: number + assistantMessageContent: unknown[] + userMessageContent: Anthropic.ToolResultBlockParam[] + didCompleteReadingStream: boolean + didRejectTool: boolean + didAlreadyUseTool: boolean + consecutiveMistakeCount: number + clineMessages: unknown[] + api: { getModel: () => { id: string; info: Record } } + recordToolUsage: ReturnType + recordToolError: ReturnType + toolRepetitionDetector: { check: ReturnType } + providerRef: { + deref: () => { + getState: ReturnType + getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + } + } + say: ReturnType + ask: ReturnType + pushToolResultToUserContent: ReturnType +} + +describe("presentAssistantMessage - tool usage attribution", () => { + let mockTask: MockTask + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(validateToolUse).mockImplementation(() => undefined) + + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + clineMessages: [], + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + pushToolResultToUserContent: vi.fn(), + } + + mockTask.pushToolResultToUserContent = vi + .fn() + .mockImplementation((toolResult: Anthropic.ToolResultBlockParam) => { + const existingResult = mockTask.userMessageContent.find( + (block) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) + }) + + it("records exactly one attempt for a normal static tool", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_1", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledTimes(1) + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("read_file") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file") + }) + + it("records a valid dynamic mcp_ tool name as use_mcp_tool", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_mcp", + name: "mcp_my_server_do_thing", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool") + }) + + it("records a malformed mcp_ tool name as use_mcp_tool, not the raw name", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_mcp_bad", + name: "mcp_", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(mockTask.recordToolUsage).not.toHaveBeenCalledWith("mcp_") + }) + + it("records a safe failure key without leaking the raw tool name when validation fails", async () => { + vi.mocked(validateToolUse).mockImplementation(() => { + throw new Error('Tool "read_file" is not allowed in this mode.') + }) + + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_bad_mode", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + // A known static tool that fails validation still maps to its own name + // (it's a real, recognized tool - just disallowed here), never left raw/unmapped. + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file", expect.any(String)) + // No success attempt should be recorded for a validation failure. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + }) + + it("records invalid_tool_call, not the raw name, when an arbitrary unknown tool fails validation", async () => { + vi.mocked(validateToolUse).mockImplementation(() => { + throw new Error('Unknown tool "totally_made_up_tool". This tool does not exist.') + }) + + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_unknown", + name: "totally_made_up_tool", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolError).toHaveBeenCalledWith("invalid_tool_call", expect.any(String)) + expect(mockTask.recordToolError).not.toHaveBeenCalledWith("totally_made_up_tool", expect.anything()) + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + }) + + describe("native mcp_tool_use block", () => { + it("records exactly one attempt once the MCP tool's own validation passes", async () => { + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + getMcpHub: () => ({ + findServerNameBySanitizedName: () => "my_server", + }), + }), + } + + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledTimes(1) + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool") + }) + + it("records no attempt when the MCP server is not on the mode's allow-list", async () => { + vi.mocked(getModeBySlug).mockReturnValueOnce({ + slug: "code", + name: "Code", + roleDefinition: "", + groups: [], + allowedMcpServers: ["some-other-server"], + }) + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + getMcpHub: () => ({ + findServerNameBySanitizedName: () => "my_server", + }), + }), + } + + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_disallowed", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + // The server is disallowed, so the call never reaches onValidated: + // no success attempt is recorded for a call that was never permitted to execute. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(TelemetryService.instance.captureToolUsage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 8e6c8d9d9e..819cfffcd9 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -101,9 +101,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Verify consecutiveMistakeCount was incremented expect(mockTask.consecutiveMistakeCount).toBe(1) - // Verify recordToolError was called + // Verify recordToolError was called with a safe static key, never the + // raw model-controlled tool name. expect(mockTask.recordToolError).toHaveBeenCalledWith( - "nonexistent_tool", + "invalid_tool_call", expect.stringContaining("Unknown tool"), ) @@ -135,8 +136,9 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Verify consecutiveMistakeCount was incremented expect(mockTask.consecutiveMistakeCount).toBe(1) - // Verify recordToolError was called - expect(mockTask.recordToolError).toHaveBeenCalled() + // Verify recordToolError was called with a safe static key, never the + // raw model-reported tool name ("fake_tool_that_does_not_exist"). + expect(mockTask.recordToolError).toHaveBeenCalledWith("invalid_tool_call", expect.anything()) // Verify error message was shown to user expect(mockTask.say).toHaveBeenCalledWith("error", expect.anything()) diff --git a/src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts b/src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts new file mode 100644 index 0000000000..0190fd38d4 --- /dev/null +++ b/src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts @@ -0,0 +1,43 @@ +// npx vitest src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts + +import { describe, it, expect, vi } from "vitest" + +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn((toolName: string) => + ["read_file", "write_to_file", "ask_followup_question", "attempt_completion", "use_mcp_tool"].includes( + toolName, + ), + ), +})) + +import { toTelemetryToolName } from "../presentAssistantMessage" + +describe("toTelemetryToolName", () => { + it("maps a known static tool to its own name", () => { + expect(toTelemetryToolName("read_file", false, undefined)).toBe("read_file") + }) + + it("maps a registered custom tool to custom_tool", () => { + expect(toTelemetryToolName("my_custom_tool", true, undefined)).toBe("custom_tool") + }) + + it("maps a valid dynamic mcp_ tool name to use_mcp_tool", () => { + expect(toTelemetryToolName("mcp_my_server_do_thing", false, undefined)).toBe("use_mcp_tool") + }) + + it("maps a malformed mcp_ tool name to use_mcp_tool", () => { + expect(toTelemetryToolName("mcp_", false, undefined)).toBe("use_mcp_tool") + }) + + it("maps an arbitrary unknown tool name to invalid_tool_call", () => { + expect(toTelemetryToolName("drop_table_users", false, undefined)).toBe("invalid_tool_call") + }) + + it("never returns the raw name for an unrecognized tool", () => { + const raw = "'; DROP TABLE users; --" + const result = toTelemetryToolName(raw, false, undefined) + expect(result).not.toBe(raw) + expect(result).toBe("invalid_tool_call") + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index f71b5cc1bd..7383a7a35a 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -41,6 +41,31 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +/** + * Maps a raw, potentially model-controlled tool name to a safe analytics key. + * Never returns the raw name unless it is a known static tool, so an + * arbitrary model-supplied string can never become a `toolsUsed` property key. + */ +export function toTelemetryToolName( + toolName: string, + isCustomTool: boolean, + experiments?: Record, +): ToolName { + if (isCustomTool) { + return "custom_tool" + } + + if (toolName.startsWith("mcp_")) { + return "use_mcp_tool" + } + + if (isValidToolName(toolName, experiments)) { + return toolName + } + + return "invalid_tool_call" +} + /** * Processes and presents assistant message content to the user interface. * @@ -233,11 +258,6 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult(formatResponse.toolError(errorString)) } - if (!mcpBlock.partial) { - cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics - TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool") - } - // Resolve sanitized server name back to original server name // The serverName from parsing is sanitized (e.g., "my_server" from "my server") // We need the original name to find the actual MCP connection @@ -273,6 +293,12 @@ export async function presentAssistantMessage(cline: Task) { askApproval, handleError, pushToolResult, + onValidated: mcpBlock.partial + ? undefined + : () => { + cline.recordToolUsage("use_mcp_tool") + TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool") + }, }) break } @@ -302,14 +328,10 @@ export async function presentAssistantMessage(cline: Task) { if (!toolCallId) { const errorMessage = "Invalid tool call: missing tool_use.id. XML tool calls are no longer supported. Remove any XML tool markup (e.g. ...) and use native tool calling instead." - // Record a tool error for visibility/telemetry. Use the reported tool name if present. + // Record a safe, static analytics key. Never key telemetry on the + // model-reported tool name, which is untrusted here. try { - if ( - typeof (cline as any).recordToolError === "function" && - typeof (block as any).name === "string" - ) { - ;(cline as any).recordToolError((block as any).name as ToolName, errorMessage) - } + cline.recordToolError("invalid_tool_call", errorMessage) } catch { // Best-effort only } @@ -425,7 +447,7 @@ export async function presentAssistantMessage(cline: Task) { cline.consecutiveMistakeCount++ try { - cline.recordToolError(block.name as ToolName, errorMessage) + cline.recordToolError(toTelemetryToolName(block.name, false, stateExperiments), errorMessage) } catch { // Best-effort only } @@ -553,23 +575,6 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult(formatResponse.toolError(errorString)) } - if (!block.partial) { - // Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools) - const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name) - const recordName = isCustomTool ? "custom_tool" : block.name - cline.recordToolUsage(recordName) - TelemetryService.instance.captureToolUsage(cline.taskId, recordName) - - // Track legacy format usage for read_file tool (for migration monitoring) - if (block.name === "read_file" && block.usedLegacyFormat) { - const modelInfo = cline.api.getModel() - TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, { - taskId: cline.taskId, - model: modelInfo?.id, - }) - } - } - // Validate tool use before execution - ONLY for complete (non-partial) blocks. // Validating partial blocks would cause validation errors to be thrown repeatedly // during streaming, pushing multiple tool_results for the same tool_use_id and @@ -582,6 +587,8 @@ export async function presentAssistantMessage(cline: Task) { const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) + const isCustomTool = Boolean(stateExperiments?.customTools && customToolRegistry.has(block.name)) + try { const toolRequirements = disabledTools?.reduce( @@ -619,8 +626,30 @@ export async function presentAssistantMessage(cline: Task) { is_error: true, }) + // Record a safe failure key. Never key telemetry on the raw, + // model-controlled tool name. + cline.recordToolError( + toTelemetryToolName(block.name, isCustomTool, stateExperiments), + error.message, + ) + break } + + // Validation passed: record exactly one attempt at this single + // central point. Individual tool handlers must not also record + // usage, or the attempt would be double-counted. + const recordName = toTelemetryToolName(block.name, isCustomTool, stateExperiments) + cline.recordToolUsage(recordName) + TelemetryService.instance.captureToolUsage(cline.taskId, recordName) + + // Track legacy format usage for read_file tool (for migration monitoring) + if (block.name === "read_file" && block.usedLegacyFormat) { + TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, { + taskId: cline.taskId, + model: modelInfo?.id, + }) + } } // Check for identical consecutive tool calls. @@ -903,7 +932,7 @@ export async function presentAssistantMessage(cline: Task) { // Not a custom tool - handle as unknown tool error const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` cline.consecutiveMistakeCount++ - cline.recordToolError(block.name as ToolName, errorMessage) + cline.recordToolError("invalid_tool_call", errorMessage) await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) // Push tool_result directly WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 3f3295404b..56b2bf8909 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -131,7 +131,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } task.consecutiveMistakeCount = 0 - task.recordToolUsage("apply_patch") } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 7d574068a9..eef419da4d 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -11,6 +11,15 @@ export interface ToolCallbacks { handleError: HandleError pushToolResult: PushToolResult toolCallId?: string + /** + * Optional hook invoked once a tool's own internal validation (params, + * target existence, permissions) has passed, before side-effecting + * execution begins. Used by callers that must defer telemetry attribution + * until validation this deep can't be done from the outside (e.g. native + * MCP tool calls, whose server/tool/allow-list checks live inside + * UseMcpToolTool rather than the shared validateToolUse path). + */ + onValidated?: () => void } /** diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 2495a372bc..a7301e2ac9 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -463,8 +463,6 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) - // Record successful tool usage and cleanup - task.recordToolUsage("edit_file") await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 79338c17a6..2ae8bf4ed0 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -229,8 +229,6 @@ export class EditTool extends BaseTool<"edit"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) - // Record successful tool usage and cleanup - task.recordToolUsage("edit") await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/GenerateImageTool.ts b/src/core/tools/GenerateImageTool.ts index c32fc85bf1..b036a71977 100644 --- a/src/core/tools/GenerateImageTool.ts +++ b/src/core/tools/GenerateImageTool.ts @@ -242,8 +242,6 @@ export class GenerateImageTool extends BaseTool<"generate_image"> { task.didEditFile = true - task.recordToolUsage("generate_image") - const fullImagePath = path.join(task.cwd, finalPath) let imageUri = provider?.convertToWebviewUri?.(fullImagePath) ?? vscode.Uri.file(fullImagePath).toString() diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 2d8817364f..e29b124010 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -225,8 +225,6 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) - // Record successful tool usage and cleanup - task.recordToolUsage("search_replace") await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index da5ceb9403..55a0bec1e5 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -28,7 +28,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { readonly name = "use_mcp_tool" as const async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise { - const { askApproval, handleError, pushToolResult } = callbacks + const { askApproval, handleError, pushToolResult, onValidated } = callbacks try { // Validate parameters @@ -66,6 +66,10 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { // Reset mistake count on successful validation task.consecutiveMistakeCount = 0 + // All internal validation (params, tool existence, server allow-list) has + // passed. Only now is it safe to attribute this as an attempted tool use. + onValidated?.() + // Get user approval const completeMessage = JSON.stringify({ type: "use_mcp_tool", diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 0e7343905e..1ff8d52a8d 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -560,7 +560,9 @@ describe("editFileTool", () => { expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) - expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit_file") + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index cbf635554c..a5f665b9e5 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -345,7 +345,9 @@ describe("editTool", () => { expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) - expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit") + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index ed08f9acbd..5cf10790d4 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -314,7 +314,9 @@ describe("searchReplaceTool", () => { expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockCline.didEditFile).toBe(true) - expect(mockCline.recordToolUsage).toHaveBeenCalledWith("search_replace") + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockCline.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 608e190d04..f7b107f6d4 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -611,7 +611,7 @@ }, "core/assistant-message/presentAssistantMessage.ts": { "@typescript-eslint/no-explicit-any": { - "count": 7 + "count": 3 } }, "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index d2dd9907b1..1a1fb03200 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -290,6 +290,7 @@ export const TOOL_DISPLAY_NAMES: Record = { skill: "load skill", generate_image: "generate images", custom_tool: "use custom tools", + invalid_tool_call: "invalid tool call", } as const // Define available tool groups.