Skip to content

Commit eed6a84

Browse files
committed
feat: add telemetry for slash command usage
- Add SLASH_COMMAND_USED telemetry event type - Implement slash command detection with regex pattern matching - Classify commands as mode_switch vs custom types - Integrate telemetry capture in user input processing - Add comprehensive test coverage for detection logic and telemetry
1 parent 82a3212 commit eed6a84

File tree

6 files changed

+399
-0
lines changed

6 files changed

+399
-0
lines changed

packages/telemetry/src/TelemetryService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ export class TelemetryService {
226226
this.captureEvent(TelemetryEventName.TITLE_BUTTON_CLICKED, { button })
227227
}
228228

229+
/**
230+
* Captures a slash command usage event
231+
* @param taskId The task ID where the command was used
232+
* @param commandType The type of command (custom or mode_switch)
233+
* @param commandName The name of the command used
234+
*/
235+
public captureSlashCommandUsed(taskId: string, commandType: "custom" | "mode_switch", commandName: string): void {
236+
this.captureEvent(TelemetryEventName.SLASH_COMMAND_USED, { taskId, commandType, commandName })
237+
}
238+
229239
/**
230240
* Checks if telemetry is currently enabled
231241
* @returns Whether telemetry is enabled
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// npx vitest run packages/telemetry/src/__tests__/TelemetryService.slashCommands.test.ts
2+
3+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
4+
import { TelemetryService } from "../TelemetryService"
5+
import { TelemetryEventName } from "@roo-code/types"
6+
7+
describe("TelemetryService - Slash Commands", () => {
8+
let telemetryService: TelemetryService
9+
let mockClient: {
10+
capture: ReturnType<typeof vi.fn>
11+
setProvider: ReturnType<typeof vi.fn>
12+
updateTelemetryState: ReturnType<typeof vi.fn>
13+
isTelemetryEnabled: ReturnType<typeof vi.fn>
14+
shutdown: ReturnType<typeof vi.fn>
15+
}
16+
17+
beforeEach(() => {
18+
// Reset the singleton instance
19+
;(TelemetryService as unknown as { _instance: TelemetryService | null })._instance = null
20+
21+
mockClient = {
22+
capture: vi.fn(),
23+
setProvider: vi.fn(),
24+
updateTelemetryState: vi.fn(),
25+
isTelemetryEnabled: vi.fn().mockReturnValue(true),
26+
shutdown: vi.fn(),
27+
}
28+
29+
telemetryService = TelemetryService.createInstance([mockClient])
30+
})
31+
32+
afterEach(() => {
33+
// Clean up singleton instance after each test
34+
;(TelemetryService as unknown as { _instance: TelemetryService | null })._instance = null
35+
})
36+
37+
describe("captureSlashCommandUsed", () => {
38+
it("should capture custom slash command usage", () => {
39+
const taskId = "test-task-123"
40+
const commandType = "custom"
41+
const commandName = "deploy"
42+
43+
telemetryService.captureSlashCommandUsed(taskId, commandType, commandName)
44+
45+
expect(mockClient.capture).toHaveBeenCalledWith({
46+
event: TelemetryEventName.SLASH_COMMAND_USED,
47+
properties: {
48+
taskId,
49+
commandType,
50+
commandName,
51+
},
52+
})
53+
})
54+
55+
it("should capture mode switch slash command usage", () => {
56+
const taskId = "test-task-456"
57+
const commandType = "mode_switch"
58+
const commandName = "code"
59+
60+
telemetryService.captureSlashCommandUsed(taskId, commandType, commandName)
61+
62+
expect(mockClient.capture).toHaveBeenCalledWith({
63+
event: TelemetryEventName.SLASH_COMMAND_USED,
64+
properties: {
65+
taskId,
66+
commandType,
67+
commandName,
68+
},
69+
})
70+
})
71+
72+
it("should handle multiple slash command captures", () => {
73+
const taskId = "test-task-789"
74+
75+
telemetryService.captureSlashCommandUsed(taskId, "custom", "build")
76+
telemetryService.captureSlashCommandUsed(taskId, "mode_switch", "debug")
77+
telemetryService.captureSlashCommandUsed(taskId, "custom", "test")
78+
79+
expect(mockClient.capture).toHaveBeenCalledTimes(3)
80+
expect(mockClient.capture).toHaveBeenNthCalledWith(1, {
81+
event: TelemetryEventName.SLASH_COMMAND_USED,
82+
properties: {
83+
taskId,
84+
commandType: "custom",
85+
commandName: "build",
86+
},
87+
})
88+
expect(mockClient.capture).toHaveBeenNthCalledWith(2, {
89+
event: TelemetryEventName.SLASH_COMMAND_USED,
90+
properties: {
91+
taskId,
92+
commandType: "mode_switch",
93+
commandName: "debug",
94+
},
95+
})
96+
expect(mockClient.capture).toHaveBeenNthCalledWith(3, {
97+
event: TelemetryEventName.SLASH_COMMAND_USED,
98+
properties: {
99+
taskId,
100+
commandType: "custom",
101+
commandName: "test",
102+
},
103+
})
104+
})
105+
106+
it("should not capture when service is not ready", () => {
107+
// Reset the instance to test empty service
108+
;(TelemetryService as unknown as { _instance: TelemetryService | null })._instance = null
109+
const emptyService = TelemetryService.createInstance([])
110+
111+
emptyService.captureSlashCommandUsed("task-id", "custom", "command")
112+
113+
// Should not throw and should not call any client methods
114+
expect(mockClient.capture).not.toHaveBeenCalled()
115+
})
116+
})
117+
})

packages/types/src/telemetry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export enum TelemetryEventName {
6666
SHELL_INTEGRATION_ERROR = "Shell Integration Error",
6767
CONSECUTIVE_MISTAKE_ERROR = "Consecutive Mistake Error",
6868
CODE_INDEX_ERROR = "Code Index Error",
69+
SLASH_COMMAND_USED = "Slash Command Used",
6970
}
7071

7172
/**
@@ -167,6 +168,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
167168
TelemetryEventName.TAB_SHOWN,
168169
TelemetryEventName.MODE_SETTINGS_CHANGED,
169170
TelemetryEventName.CUSTOM_MODE_CREATED,
171+
TelemetryEventName.SLASH_COMMAND_USED,
170172
]),
171173
properties: telemetryPropertiesSchema,
172174
}),

src/core/task/Task.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import {
2929
import { TelemetryService } from "@roo-code/telemetry"
3030
import { CloudService } from "@roo-code/cloud"
3131

32+
// utils
33+
import { detectSlashCommands } from "../../utils/slashCommandDetection"
34+
3235
// api
3336
import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api"
3437
import { ApiStream } from "../../api/transform/stream"
@@ -714,6 +717,14 @@ export class Task extends EventEmitter<ClineEvents> {
714717
this.askResponse = askResponse
715718
this.askResponseText = text
716719
this.askResponseImages = images
720+
721+
// Detect and track slash command usage
722+
if (askResponse === "messageResponse" && text && TelemetryService.hasInstance()) {
723+
const slashCommands = detectSlashCommands(text)
724+
for (const command of slashCommands) {
725+
TelemetryService.instance.captureSlashCommandUsed(this.taskId, command.type, command.commandName)
726+
}
727+
}
717728
}
718729

719730
async handleTerminalOperation(terminalOperation: "continue" | "abort") {
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// npx vitest run src/utils/__tests__/slashCommandDetection.spec.ts
2+
3+
import { describe, it, expect, vi, beforeEach } from "vitest"
4+
import { detectSlashCommands, getFirstSlashCommand } from "../slashCommandDetection"
5+
6+
// Mock the modes module
7+
vi.mock("../../shared/modes", () => ({
8+
modes: [
9+
{ slug: "code", name: "Code" },
10+
{ slug: "architect", name: "Architect" },
11+
{ slug: "debug", name: "Debug" },
12+
{ slug: "ask", name: "Ask" },
13+
],
14+
getModeBySlug: vi.fn((slug: string) => {
15+
const modeMap: Record<string, any> = {
16+
code: { slug: "code", name: "Code" },
17+
architect: { slug: "architect", name: "Architect" },
18+
debug: { slug: "debug", name: "Debug" },
19+
ask: { slug: "ask", name: "Ask" },
20+
}
21+
return modeMap[slug]
22+
}),
23+
}))
24+
25+
describe("slashCommandDetection", () => {
26+
describe("detectSlashCommands", () => {
27+
it("should detect mode switch commands", () => {
28+
const text = "Let me switch to /code mode to implement this"
29+
const commands = detectSlashCommands(text)
30+
31+
expect(commands).toHaveLength(1)
32+
expect(commands[0]).toEqual({
33+
fullCommand: "/code",
34+
commandName: "code",
35+
type: "mode_switch",
36+
})
37+
})
38+
39+
it("should detect custom commands", () => {
40+
const text = "I'll use /deploy to deploy this application"
41+
const commands = detectSlashCommands(text)
42+
43+
expect(commands).toHaveLength(1)
44+
expect(commands[0]).toEqual({
45+
fullCommand: "/deploy",
46+
commandName: "deploy",
47+
type: "custom",
48+
})
49+
})
50+
51+
it("should detect multiple commands in one text", () => {
52+
const text = "First /architect the solution, then /code it, and finally /deploy"
53+
const commands = detectSlashCommands(text)
54+
55+
expect(commands).toHaveLength(3)
56+
expect(commands[0]).toEqual({
57+
fullCommand: "/architect",
58+
commandName: "architect",
59+
type: "mode_switch",
60+
})
61+
expect(commands[1]).toEqual({
62+
fullCommand: "/code",
63+
commandName: "code",
64+
type: "mode_switch",
65+
})
66+
expect(commands[2]).toEqual({
67+
fullCommand: "/deploy",
68+
commandName: "deploy",
69+
type: "custom",
70+
})
71+
})
72+
73+
it("should detect commands at the beginning of text", () => {
74+
const text = "/debug this issue please"
75+
const commands = detectSlashCommands(text)
76+
77+
expect(commands).toHaveLength(1)
78+
expect(commands[0]).toEqual({
79+
fullCommand: "/debug",
80+
commandName: "debug",
81+
type: "mode_switch",
82+
})
83+
})
84+
85+
it("should detect commands at the beginning of new lines", () => {
86+
const text = "First do this:\n/architect the solution\nThen:\n/code the implementation"
87+
const commands = detectSlashCommands(text)
88+
89+
expect(commands).toHaveLength(2)
90+
expect(commands[0]).toEqual({
91+
fullCommand: "/architect",
92+
commandName: "architect",
93+
type: "mode_switch",
94+
})
95+
expect(commands[1]).toEqual({
96+
fullCommand: "/code",
97+
commandName: "code",
98+
type: "mode_switch",
99+
})
100+
})
101+
102+
it("should handle commands with hyphens and underscores", () => {
103+
const text = "Use /my-custom_command to do this"
104+
const commands = detectSlashCommands(text)
105+
106+
expect(commands).toHaveLength(1)
107+
expect(commands[0]).toEqual({
108+
fullCommand: "/my-custom_command",
109+
commandName: "my-custom_command",
110+
type: "custom",
111+
})
112+
})
113+
114+
it("should not detect invalid slash patterns", () => {
115+
const text = "This is a file path /home/user/file.txt and a URL https://example.com/path"
116+
const commands = detectSlashCommands(text)
117+
118+
expect(commands).toHaveLength(0)
119+
})
120+
121+
it("should not detect slash commands in the middle of words", () => {
122+
const text = "The file://path and http://example.com don't contain commands"
123+
const commands = detectSlashCommands(text)
124+
125+
expect(commands).toHaveLength(0)
126+
})
127+
128+
it("should handle empty or null input", () => {
129+
expect(detectSlashCommands("")).toEqual([])
130+
expect(detectSlashCommands(null as any)).toEqual([])
131+
expect(detectSlashCommands(undefined as any)).toEqual([])
132+
})
133+
134+
it("should handle text with no commands", () => {
135+
const text = "This is just regular text with no slash commands"
136+
const commands = detectSlashCommands(text)
137+
138+
expect(commands).toEqual([])
139+
})
140+
141+
it("should detect commands that start with numbers after the slash", () => {
142+
const text = "Use /2fa to enable two-factor authentication"
143+
const commands = detectSlashCommands(text)
144+
145+
expect(commands).toHaveLength(0) // Should not match as it starts with a number
146+
})
147+
148+
it("should detect commands that contain numbers", () => {
149+
const text = "Use /deploy2prod to deploy to production"
150+
const commands = detectSlashCommands(text)
151+
152+
expect(commands).toHaveLength(1)
153+
expect(commands[0]).toEqual({
154+
fullCommand: "/deploy2prod",
155+
commandName: "deploy2prod",
156+
type: "custom",
157+
})
158+
})
159+
})
160+
161+
describe("getFirstSlashCommand", () => {
162+
it("should return the first command when multiple exist", () => {
163+
const text = "First /architect then /code then /deploy"
164+
const command = getFirstSlashCommand(text)
165+
166+
expect(command).toEqual({
167+
fullCommand: "/architect",
168+
commandName: "architect",
169+
type: "mode_switch",
170+
})
171+
})
172+
173+
it("should return null when no commands exist", () => {
174+
const text = "No commands here"
175+
const command = getFirstSlashCommand(text)
176+
177+
expect(command).toBeNull()
178+
})
179+
180+
it("should return the single command when only one exists", () => {
181+
const text = "Please /debug this issue"
182+
const command = getFirstSlashCommand(text)
183+
184+
expect(command).toEqual({
185+
fullCommand: "/debug",
186+
commandName: "debug",
187+
type: "mode_switch",
188+
})
189+
})
190+
})
191+
})

0 commit comments

Comments
 (0)