Skip to content

Commit e4a416f

Browse files
committed
fix(telemetry): narrow UseMcpToolTool callback, harden test mocks, close coverage gaps
1 parent 13a9e95 commit e4a416f

6 files changed

Lines changed: 151 additions & 28 deletions

File tree

src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@ vi.mock("../../../shared/modes", async (importOriginal) => {
1515
getModeBySlug: vi.fn(actual.getModeBySlug),
1616
}
1717
})
18-
vi.mock("../../tools/validateToolUse", () => ({
19-
validateToolUse: vi.fn(),
20-
isValidToolName: vi.fn((toolName: string) =>
21-
["read_file", "write_to_file", "ask_followup_question", "attempt_completion", "use_mcp_tool"].includes(
22-
toolName,
23-
),
24-
),
25-
}))
18+
// isValidToolName is left as the real implementation (only validateToolUse is
19+
// mocked): it has its own independent mcp_ prefix carve-out, and a hand-rolled
20+
// mock allowlist here would mask a regression in toTelemetryToolName's
21+
// ordering relative to isValidToolName.
22+
vi.mock("../../tools/validateToolUse", async (importOriginal) => {
23+
const actual = await importOriginal<typeof import("../../tools/validateToolUse")>()
24+
return {
25+
...actual,
26+
validateToolUse: vi.fn(),
27+
}
28+
})
2629

2730
vi.mock("@roo-code/core", () => ({
2831
customToolRegistry: {

src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
import { describe, it, expect, vi } from "vitest"
44

5-
vi.mock("../../tools/validateToolUse", () => ({
6-
validateToolUse: vi.fn(),
7-
isValidToolName: vi.fn((toolName: string) =>
8-
["read_file", "write_to_file", "ask_followup_question", "attempt_completion", "use_mcp_tool"].includes(
9-
toolName,
10-
),
11-
),
12-
}))
5+
// Only validateToolUse itself needs mocking (unused by toTelemetryToolName,
6+
// but imported by the same module). isValidToolName is left as the real
7+
// implementation: it has its own independent mcp_ prefix carve-out, and a
8+
// hand-rolled mock allowlist here would mask a regression where
9+
// toTelemetryToolName's ordering relative to isValidToolName changes.
10+
vi.mock("../../tools/validateToolUse", async (importOriginal) => {
11+
const actual = await importOriginal<typeof import("../../tools/validateToolUse")>()
12+
return {
13+
...actual,
14+
validateToolUse: vi.fn(),
15+
}
16+
})
1317

1418
import { toTelemetryToolName } from "../presentAssistantMessage"
1519

@@ -40,4 +44,11 @@ describe("toTelemetryToolName", () => {
4044
expect(result).not.toBe(raw)
4145
expect(result).toBe("invalid_tool_call")
4246
})
47+
48+
it("maps mcp_ names to use_mcp_tool against the real isValidToolName, not a mock allowlist", () => {
49+
// isValidToolName is unmocked in this file (see the vi.mock factory above),
50+
// so this exercises the real mcp_ prefix carve-out ordering rather than one
51+
// a hand-rolled mock could silently keep agreeing with after a regression.
52+
expect(toTelemetryToolName("mcp_my_server_do_thing", false, undefined)).toBe("use_mcp_tool")
53+
})
4354
})

src/core/tools/BaseTool.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,6 @@ export interface ToolCallbacks {
1111
handleError: HandleError
1212
pushToolResult: PushToolResult
1313
toolCallId?: string
14-
/**
15-
* Optional hook invoked once a tool's own internal validation (params,
16-
* target existence, permissions) has passed, before side-effecting
17-
* execution begins. Used by callers that must defer telemetry attribution
18-
* until validation this deep can't be done from the outside (e.g. native
19-
* MCP tool calls, whose server/tool/allow-list checks live inside
20-
* UseMcpToolTool rather than the shared validateToolUse path).
21-
*/
22-
onValidated?: () => void
2314
}
2415

2516
/**
@@ -117,9 +108,16 @@ export abstract class BaseTool<TName extends ToolName> {
117108
*
118109
* @param task - Task instance
119110
* @param block - ToolUse block from assistant message
120-
* @param callbacks - Tool execution callbacks
111+
* @param callbacks - Tool execution callbacks. Accepts any subclass-specific
112+
* extension of ToolCallbacks (e.g. UseMcpToolCallbacks) so callers can pass
113+
* extra fields through to a matching execute() override without widening
114+
* the shared ToolCallbacks interface for every tool.
121115
*/
122-
async handle(task: Task, block: ToolUse<TName>, callbacks: ToolCallbacks): Promise<void> {
116+
async handle<TCallbacks extends ToolCallbacks>(
117+
task: Task,
118+
block: ToolUse<TName>,
119+
callbacks: TCallbacks,
120+
): Promise<void> {
123121
// Handle partial messages
124122
if (block.partial) {
125123
try {

src/core/tools/UseMcpToolTool.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ interface UseMcpToolParams {
1515
arguments?: Record<string, unknown>
1616
}
1717

18+
/**
19+
* Extends the shared callbacks with a hook invoked once this tool's own
20+
* internal validation (params, tool existence, server allow-list) has
21+
* passed, before side-effecting execution begins. Native MCP tool calls
22+
* (presentAssistantMessage's mcp_tool_use branch) use this to defer telemetry
23+
* attribution past validation that lives inside this class rather than the
24+
* shared validateToolUse path.
25+
*/
26+
export interface UseMcpToolCallbacks extends ToolCallbacks {
27+
onValidated?: () => void
28+
}
29+
1830
type ValidationResult =
1931
| { isValid: false }
2032
| {
@@ -27,7 +39,7 @@ type ValidationResult =
2739
export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
2840
readonly name = "use_mcp_tool" as const
2941

30-
async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
42+
async execute(params: UseMcpToolParams, task: Task, callbacks: UseMcpToolCallbacks): Promise<void> {
3143
const { askApproval, handleError, pushToolResult, onValidated } = callbacks
3244

3345
try {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// npx vitest run core/tools/__tests__/applyPatchTool.execute.spec.ts
2+
3+
import type { MockedFunction } from "vitest"
4+
5+
import { fileExistsAtPath } from "../../../utils/fs"
6+
import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
7+
import type { Task } from "../../task/Task"
8+
import { ApplyPatchTool } from "../ApplyPatchTool"
9+
10+
vi.mock("fs/promises", () => ({
11+
default: {
12+
readFile: vi.fn().mockResolvedValue("original file content\n"),
13+
unlink: vi.fn().mockResolvedValue(undefined),
14+
},
15+
}))
16+
17+
vi.mock("../../../utils/fs", () => ({
18+
fileExistsAtPath: vi.fn().mockResolvedValue(true),
19+
}))
20+
21+
vi.mock("../../../utils/pathUtils", () => ({
22+
isPathOutsideWorkspace: vi.fn().mockReturnValue(false),
23+
}))
24+
25+
describe("ApplyPatchTool.execute - delete file success path", () => {
26+
const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction<typeof fileExistsAtPath>
27+
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
28+
29+
let tool: ApplyPatchTool
30+
let mockTask: Pick<
31+
Task,
32+
| "cwd"
33+
| "consecutiveMistakeCount"
34+
| "recordToolUsage"
35+
| "recordToolError"
36+
| "rooIgnoreController"
37+
| "rooProtectedController"
38+
| "say"
39+
| "processQueuedMessages"
40+
| "didEditFile"
41+
>
42+
let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise<boolean>>
43+
let mockHandleError: MockedFunction<(...args: unknown[]) => Promise<void>>
44+
let mockPushToolResult: MockedFunction<(...args: unknown[]) => void>
45+
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
49+
mockedFileExistsAtPath.mockResolvedValue(true)
50+
mockedIsPathOutsideWorkspace.mockReturnValue(false)
51+
52+
mockTask = {
53+
cwd: "/workspace/project",
54+
consecutiveMistakeCount: 0,
55+
recordToolUsage: vi.fn(),
56+
recordToolError: vi.fn(),
57+
rooIgnoreController: {
58+
validateAccess: vi.fn().mockReturnValue(true),
59+
} as unknown as Task["rooIgnoreController"],
60+
rooProtectedController: {
61+
isWriteProtected: vi.fn().mockReturnValue(false),
62+
} as unknown as Task["rooProtectedController"],
63+
say: vi.fn().mockResolvedValue(undefined),
64+
processQueuedMessages: vi.fn(),
65+
didEditFile: false,
66+
}
67+
68+
mockAskApproval = vi.fn().mockResolvedValue(true)
69+
mockHandleError = vi.fn().mockResolvedValue(undefined)
70+
mockPushToolResult = vi.fn()
71+
72+
tool = new ApplyPatchTool()
73+
})
74+
75+
it("deletes the file and records no local tool usage on success", async () => {
76+
const patch = `*** Begin Patch
77+
*** Delete File: src/obsolete.ts
78+
*** End Patch`
79+
80+
await tool.execute({ patch }, mockTask as Task, {
81+
askApproval: mockAskApproval,
82+
handleError: mockHandleError,
83+
pushToolResult: mockPushToolResult,
84+
})
85+
86+
expect(mockAskApproval).toHaveBeenCalled()
87+
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted"))
88+
expect(mockTask.didEditFile).toBe(true)
89+
expect(mockHandleError).not.toHaveBeenCalled()
90+
91+
// Usage is recorded once at the central presentAssistantMessage
92+
// attribution point, not locally by the handler.
93+
expect(mockTask.recordToolUsage).not.toHaveBeenCalled()
94+
expect(mockTask.recordToolError).not.toHaveBeenCalled()
95+
})
96+
})

src/core/tools/__tests__/generateImageTool.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ describe("generateImageTool", () => {
163163
expect(mockAskApproval).toHaveBeenCalled()
164164
expect(mockGenerateImage).toHaveBeenCalled()
165165
expect(mockPushToolResult).toHaveBeenCalled()
166+
// Usage is recorded once at the central presentAssistantMessage
167+
// attribution point, not locally by the handler.
168+
expect(mockCline.recordToolUsage).not.toHaveBeenCalled()
166169
})
167170

168171
it("should add cache-busting parameter to image URI", async () => {

0 commit comments

Comments
 (0)