Skip to content

Commit 9d652bb

Browse files
committed
fix(telemetry): record tool usage once centrally, sanitize raw tool names
1 parent cd29243 commit 9d652bb

15 files changed

Lines changed: 336 additions & 43 deletions

packages/types/src/tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const toolNames = [
4646
"skill",
4747
"generate_image",
4848
"custom_tool",
49+
"invalid_tool_call",
4950
] as const
5051

5152
export const toolNamesSchema = z.enum(toolNames)
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts
2+
3+
import type { Anthropic } from "@anthropic-ai/sdk"
4+
import { describe, it, expect, beforeEach, vi } from "vitest"
5+
import { presentAssistantMessage } from "../presentAssistantMessage"
6+
import { validateToolUse } from "../../tools/validateToolUse"
7+
import type { Task } from "../../task/Task"
8+
9+
vi.mock("../../task/Task")
10+
vi.mock("../../tools/validateToolUse", () => ({
11+
validateToolUse: vi.fn(),
12+
isValidToolName: vi.fn((toolName: string) =>
13+
["read_file", "write_to_file", "ask_followup_question", "attempt_completion", "use_mcp_tool"].includes(
14+
toolName,
15+
),
16+
),
17+
}))
18+
19+
vi.mock("@roo-code/core", () => ({
20+
customToolRegistry: {
21+
has: vi.fn(() => false),
22+
get: vi.fn(),
23+
},
24+
}))
25+
26+
vi.mock("@roo-code/telemetry", () => ({
27+
TelemetryService: {
28+
instance: {
29+
captureToolUsage: vi.fn(),
30+
captureConsecutiveMistakeError: vi.fn(),
31+
captureEvent: vi.fn(),
32+
},
33+
},
34+
}))
35+
36+
import { TelemetryService } from "@roo-code/telemetry"
37+
38+
interface MockTask {
39+
taskId: string
40+
instanceId: string
41+
abort: boolean
42+
presentAssistantMessageLocked: boolean
43+
presentAssistantMessageHasPendingUpdates: boolean
44+
currentStreamingContentIndex: number
45+
assistantMessageContent: unknown[]
46+
userMessageContent: Anthropic.ToolResultBlockParam[]
47+
didCompleteReadingStream: boolean
48+
didRejectTool: boolean
49+
didAlreadyUseTool: boolean
50+
consecutiveMistakeCount: number
51+
clineMessages: unknown[]
52+
api: { getModel: () => { id: string; info: Record<string, unknown> } }
53+
recordToolUsage: ReturnType<typeof vi.fn>
54+
recordToolError: ReturnType<typeof vi.fn>
55+
toolRepetitionDetector: { check: ReturnType<typeof vi.fn> }
56+
providerRef: { deref: () => { getState: ReturnType<typeof vi.fn> } }
57+
say: ReturnType<typeof vi.fn>
58+
ask: ReturnType<typeof vi.fn>
59+
pushToolResultToUserContent: ReturnType<typeof vi.fn>
60+
}
61+
62+
describe("presentAssistantMessage - tool usage attribution", () => {
63+
let mockTask: MockTask
64+
65+
beforeEach(() => {
66+
vi.clearAllMocks()
67+
vi.mocked(validateToolUse).mockImplementation(() => undefined)
68+
69+
mockTask = {
70+
taskId: "test-task-id",
71+
instanceId: "test-instance",
72+
abort: false,
73+
presentAssistantMessageLocked: false,
74+
presentAssistantMessageHasPendingUpdates: false,
75+
currentStreamingContentIndex: 0,
76+
assistantMessageContent: [],
77+
userMessageContent: [],
78+
didCompleteReadingStream: false,
79+
didRejectTool: false,
80+
didAlreadyUseTool: false,
81+
consecutiveMistakeCount: 0,
82+
clineMessages: [],
83+
api: {
84+
getModel: () => ({ id: "test-model", info: {} }),
85+
},
86+
recordToolUsage: vi.fn(),
87+
recordToolError: vi.fn(),
88+
toolRepetitionDetector: {
89+
check: vi.fn().mockReturnValue({ allowExecution: true }),
90+
},
91+
providerRef: {
92+
deref: () => ({
93+
getState: vi.fn().mockResolvedValue({
94+
mode: "code",
95+
customModes: [],
96+
}),
97+
}),
98+
},
99+
say: vi.fn().mockResolvedValue(undefined),
100+
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
101+
pushToolResultToUserContent: vi.fn(),
102+
}
103+
104+
mockTask.pushToolResultToUserContent = vi
105+
.fn()
106+
.mockImplementation((toolResult: Anthropic.ToolResultBlockParam) => {
107+
const existingResult = mockTask.userMessageContent.find(
108+
(block) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
109+
)
110+
if (existingResult) {
111+
return false
112+
}
113+
mockTask.userMessageContent.push(toolResult)
114+
return true
115+
})
116+
})
117+
118+
it("records exactly one attempt for a normal static tool", async () => {
119+
mockTask.assistantMessageContent = [
120+
{
121+
type: "tool_use",
122+
id: "call_1",
123+
name: "read_file",
124+
params: { path: "test.txt" },
125+
nativeArgs: { path: "test.txt" },
126+
partial: false,
127+
},
128+
]
129+
130+
await presentAssistantMessage(mockTask as unknown as Task)
131+
132+
expect(mockTask.recordToolUsage).toHaveBeenCalledTimes(1)
133+
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("read_file")
134+
expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledTimes(1)
135+
expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file")
136+
})
137+
138+
it("records a valid dynamic mcp_ tool name as use_mcp_tool", async () => {
139+
mockTask.assistantMessageContent = [
140+
{
141+
type: "tool_use",
142+
id: "call_mcp",
143+
name: "mcp_my_server_do_thing",
144+
params: {},
145+
nativeArgs: {},
146+
partial: false,
147+
},
148+
]
149+
150+
await presentAssistantMessage(mockTask as unknown as Task)
151+
152+
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool")
153+
expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool")
154+
})
155+
156+
it("records a malformed mcp_ tool name as use_mcp_tool, not the raw name", async () => {
157+
mockTask.assistantMessageContent = [
158+
{
159+
type: "tool_use",
160+
id: "call_mcp_bad",
161+
name: "mcp_",
162+
params: {},
163+
nativeArgs: {},
164+
partial: false,
165+
},
166+
]
167+
168+
await presentAssistantMessage(mockTask as unknown as Task)
169+
170+
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool")
171+
expect(mockTask.recordToolUsage).not.toHaveBeenCalledWith("mcp_")
172+
})
173+
174+
it("records a safe failure key without leaking the raw tool name when validation fails", async () => {
175+
vi.mocked(validateToolUse).mockImplementation(() => {
176+
throw new Error('Tool "read_file" is not allowed in this mode.')
177+
})
178+
179+
mockTask.assistantMessageContent = [
180+
{
181+
type: "tool_use",
182+
id: "call_bad_mode",
183+
name: "read_file",
184+
params: { path: "test.txt" },
185+
nativeArgs: { path: "test.txt" },
186+
partial: false,
187+
},
188+
]
189+
190+
await presentAssistantMessage(mockTask as unknown as Task)
191+
192+
// A known static tool that fails validation still maps to its own name
193+
// (it's a real, recognized tool - just disallowed here), never left raw/unmapped.
194+
expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file", expect.any(String))
195+
// No success attempt should be recorded for a validation failure.
196+
expect(mockTask.recordToolUsage).not.toHaveBeenCalled()
197+
})
198+
199+
it("records invalid_tool_call, not the raw name, when an arbitrary unknown tool fails validation", async () => {
200+
vi.mocked(validateToolUse).mockImplementation(() => {
201+
throw new Error('Unknown tool "totally_made_up_tool". This tool does not exist.')
202+
})
203+
204+
mockTask.assistantMessageContent = [
205+
{
206+
type: "tool_use",
207+
id: "call_unknown",
208+
name: "totally_made_up_tool",
209+
params: {},
210+
nativeArgs: {},
211+
partial: false,
212+
},
213+
]
214+
215+
await presentAssistantMessage(mockTask as unknown as Task)
216+
217+
expect(mockTask.recordToolError).toHaveBeenCalledWith("invalid_tool_call", expect.any(String))
218+
expect(mockTask.recordToolError).not.toHaveBeenCalledWith("totally_made_up_tool", expect.anything())
219+
expect(mockTask.recordToolUsage).not.toHaveBeenCalled()
220+
})
221+
})

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
101101
// Verify consecutiveMistakeCount was incremented
102102
expect(mockTask.consecutiveMistakeCount).toBe(1)
103103

104-
// Verify recordToolError was called
104+
// Verify recordToolError was called with a safe static key, never the
105+
// raw model-controlled tool name.
105106
expect(mockTask.recordToolError).toHaveBeenCalledWith(
106-
"nonexistent_tool",
107+
"invalid_tool_call",
107108
expect.stringContaining("Unknown tool"),
108109
)
109110

@@ -135,8 +136,9 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
135136
// Verify consecutiveMistakeCount was incremented
136137
expect(mockTask.consecutiveMistakeCount).toBe(1)
137138

138-
// Verify recordToolError was called
139-
expect(mockTask.recordToolError).toHaveBeenCalled()
139+
// Verify recordToolError was called with a safe static key, never the
140+
// raw model-reported tool name ("fake_tool_that_does_not_exist").
141+
expect(mockTask.recordToolError).toHaveBeenCalledWith("invalid_tool_call", expect.anything())
140142

141143
// Verify error message was shown to user
142144
expect(mockTask.say).toHaveBeenCalledWith("error", expect.anything())
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// npx vitest src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts
2+
3+
import { describe, it, expect, vi } from "vitest"
4+
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+
}))
13+
14+
import { toTelemetryToolName } from "../presentAssistantMessage"
15+
16+
describe("toTelemetryToolName", () => {
17+
it("maps a known static tool to its own name", () => {
18+
expect(toTelemetryToolName("read_file", false, undefined)).toBe("read_file")
19+
})
20+
21+
it("maps a registered custom tool to custom_tool", () => {
22+
expect(toTelemetryToolName("my_custom_tool", true, undefined)).toBe("custom_tool")
23+
})
24+
25+
it("maps a valid dynamic mcp_ tool name to use_mcp_tool", () => {
26+
expect(toTelemetryToolName("mcp_my_server_do_thing", false, undefined)).toBe("use_mcp_tool")
27+
})
28+
29+
it("maps a malformed mcp_ tool name to use_mcp_tool", () => {
30+
expect(toTelemetryToolName("mcp_", false, undefined)).toBe("use_mcp_tool")
31+
})
32+
33+
it("maps an arbitrary unknown tool name to invalid_tool_call", () => {
34+
expect(toTelemetryToolName("drop_table_users", false, undefined)).toBe("invalid_tool_call")
35+
})
36+
37+
it("never returns the raw name for an unrecognized tool", () => {
38+
const raw = "'; DROP TABLE users; --"
39+
const result = toTelemetryToolName(raw, false, undefined)
40+
expect(result).not.toBe(raw)
41+
expect(result).toBe("invalid_tool_call")
42+
})
43+
})

0 commit comments

Comments
 (0)