Skip to content

Commit f1b9ecc

Browse files
roomotehannesrudolph
authored andcommitted
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 a8f87d2 commit f1b9ecc

File tree

6 files changed

+400
-1
lines changed

6 files changed

+400
-1
lines changed

packages/telemetry/src/TelemetryService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,16 @@ export class TelemetryService {
243243
})
244244
}
245245

246+
/**
247+
* Captures a slash command usage event
248+
* @param taskId The task ID where the command was used
249+
* @param commandType The type of command (custom or mode_switch)
250+
* @param commandName The name of the command used
251+
*/
252+
public captureSlashCommandUsed(taskId: string, commandType: "custom" | "mode_switch", commandName: string): void {
253+
this.captureEvent(TelemetryEventName.SLASH_COMMAND_USED, { taskId, commandType, commandName })
254+
}
255+
246256
/**
247257
* Checks if telemetry is currently enabled
248258
* @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
@@ -72,6 +72,7 @@ export enum TelemetryEventName {
7272
CONSECUTIVE_MISTAKE_ERROR = "Consecutive Mistake Error",
7373
CODE_INDEX_ERROR = "Code Index Error",
7474
TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed",
75+
SLASH_COMMAND_USED = "Slash Command Used",
7576
}
7677

7778
/**
@@ -201,6 +202,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
201202
TelemetryEventName.TAB_SHOWN,
202203
TelemetryEventName.MODE_SETTINGS_CHANGED,
203204
TelemetryEventName.CUSTOM_MODE_CREATED,
205+
TelemetryEventName.SLASH_COMMAND_USED,
204206
]),
205207
properties: telemetryPropertiesSchema,
206208
}),

src/core/task/Task.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ import {
3939
import { TelemetryService } from "@roo-code/telemetry"
4040
import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
4141

42+
// utils
43+
import { detectSlashCommands } from "../../utils/slashCommandDetection"
44+
4245
// api
4346
import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api"
4447
import { ApiStream, GroundingSource } from "../../api/transform/stream"
@@ -925,8 +928,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
925928
})
926929
}
927930
}
928-
}
929931

932+
// Detect and track slash command usage
933+
if (askResponse === "messageResponse" && text && TelemetryService.hasInstance()) {
934+
const slashCommands = detectSlashCommands(text)
935+
for (const command of slashCommands) {
936+
TelemetryService.instance.captureSlashCommandUsed(this.taskId, command.type, command.commandName)
937+
}
938+
}
939+
}
940+
930941
public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) {
931942
this.handleWebviewAskResponse("yesButtonClicked", text, images)
932943
}
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)