Skip to content

Commit e13083e

Browse files
roomote[bot]roomotemrubens
authored
Skip interpolation for non-existent slash commands (#6475)
Co-authored-by: Roo Code <[email protected]> Co-authored-by: Matt Rubens <[email protected]>
1 parent 2f8fd88 commit e13083e

File tree

2 files changed

+104
-31
lines changed

2 files changed

+104
-31
lines changed

src/__tests__/command-mentions.spec.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,31 @@ describe("Command Mentions", () => {
6262
})
6363

6464
it("should handle multiple commands in message", async () => {
65+
const setupContent = "# Setup Environment\n\nRun the following commands:\n```bash\nnpm install\n```"
66+
const deployContent = "# Deploy Environment\n\nRun the following commands:\n```bash\nnpm run deploy\n```"
67+
6568
mockGetCommand
6669
.mockResolvedValueOnce({
6770
name: "setup",
68-
content: "# Setup instructions",
71+
content: setupContent,
6972
source: "project",
7073
filePath: "/project/.roo/commands/setup.md",
7174
})
7275
.mockResolvedValueOnce({
7376
name: "deploy",
74-
content: "# Deploy instructions",
77+
content: deployContent,
78+
source: "project",
79+
filePath: "/project/.roo/commands/deploy.md",
80+
})
81+
.mockResolvedValueOnce({
82+
name: "setup",
83+
content: setupContent,
84+
source: "project",
85+
filePath: "/project/.roo/commands/setup.md",
86+
})
87+
.mockResolvedValueOnce({
88+
name: "deploy",
89+
content: deployContent,
7590
source: "project",
7691
filePath: "/project/.roo/commands/deploy.md",
7792
})
@@ -82,33 +97,55 @@ describe("Command Mentions", () => {
8297

8398
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
8499
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "deploy")
85-
expect(mockGetCommand).toHaveBeenCalledTimes(2) // Both commands called
100+
expect(mockGetCommand).toHaveBeenCalledTimes(2) // Each unique command called once (optimized)
86101
expect(result).toContain('<command name="setup">')
87-
expect(result).toContain("# Setup instructions")
102+
expect(result).toContain("# Setup Environment")
88103
expect(result).toContain('<command name="deploy">')
89-
expect(result).toContain("# Deploy instructions")
104+
expect(result).toContain("# Deploy Environment")
90105
})
91106

92-
it("should handle non-existent command gracefully", async () => {
107+
it("should leave non-existent commands unchanged", async () => {
108+
mockGetCommand.mockReset()
93109
mockGetCommand.mockResolvedValue(undefined)
94110

95111
const input = "/nonexistent command"
96112
const result = await callParseMentions(input)
97113

98114
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "nonexistent")
99-
expect(result).toContain('<command name="nonexistent">')
100-
expect(result).toContain("Command 'nonexistent' not found")
101-
expect(result).toContain("</command>")
115+
// The command should remain unchanged in the text
116+
expect(result).toBe("/nonexistent command")
117+
// Should not contain any command tags
118+
expect(result).not.toContain('<command name="nonexistent">')
119+
expect(result).not.toContain("Command 'nonexistent' not found")
102120
})
103121

104-
it("should handle command loading errors", async () => {
122+
it("should handle command loading errors during existence check", async () => {
123+
mockGetCommand.mockReset()
105124
mockGetCommand.mockRejectedValue(new Error("Failed to load command"))
106125

107126
const input = "/error-command test"
108127
const result = await callParseMentions(input)
109128

129+
// When getCommand throws an error during existence check,
130+
// the command is treated as non-existent and left unchanged
131+
expect(result).toBe("/error-command test")
132+
expect(result).not.toContain('<command name="error-command">')
133+
})
134+
135+
it("should handle command loading errors during processing", async () => {
136+
// With optimization, command is loaded once and cached
137+
mockGetCommand.mockResolvedValue({
138+
name: "error-command",
139+
content: "# Error command",
140+
source: "project",
141+
filePath: "/project/.roo/commands/error-command.md",
142+
})
143+
144+
const input = "/error-command test"
145+
const result = await callParseMentions(input)
146+
110147
expect(result).toContain('<command name="error-command">')
111-
expect(result).toContain("Error loading command")
148+
expect(result).toContain("# Error command")
112149
expect(result).toContain("</command>")
113150
})
114151

@@ -246,13 +283,29 @@ npm install
246283
})
247284

248285
describe("command mention text transformation", () => {
249-
it("should transform command mentions at start of message", async () => {
286+
it("should transform existing command mentions at start of message", async () => {
287+
mockGetCommand.mockResolvedValue({
288+
name: "setup",
289+
content: "# Setup instructions",
290+
source: "project",
291+
filePath: "/project/.roo/commands/setup.md",
292+
})
293+
250294
const input = "/setup the project"
251295
const result = await callParseMentions(input)
252296

253297
expect(result).toContain("Command 'setup' (see below for command content)")
254298
})
255299

300+
it("should leave non-existent command mentions unchanged", async () => {
301+
mockGetCommand.mockResolvedValue(undefined)
302+
303+
const input = "/nonexistent the project"
304+
const result = await callParseMentions(input)
305+
306+
expect(result).toBe("/nonexistent the project")
307+
})
308+
256309
it("should process multiple commands in message", async () => {
257310
mockGetCommand
258311
.mockResolvedValueOnce({

src/core/mentions/index.ts

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
1818
import { FileContextTracker } from "../context-tracking/FileContextTracker"
1919

2020
import { RooIgnoreController } from "../ignore/RooIgnoreController"
21-
import { getCommand } from "../../services/command/commands"
21+
import { getCommand, type Command } from "../../services/command/commands"
2222

2323
import { t } from "../../i18n"
2424

@@ -86,13 +86,38 @@ export async function parseMentions(
8686
maxReadFileLine?: number,
8787
): Promise<string> {
8888
const mentions: Set<string> = new Set()
89-
const commandMentions: Set<string> = new Set()
89+
const validCommands: Map<string, Command> = new Map()
9090

91-
// First pass: extract command mentions (starting with /)
92-
let parsedText = text.replace(commandRegexGlobal, (match, commandName) => {
93-
commandMentions.add(commandName)
94-
return `Command '${commandName}' (see below for command content)`
95-
})
91+
// First pass: check which command mentions exist and cache the results
92+
const commandMatches = Array.from(text.matchAll(commandRegexGlobal))
93+
const uniqueCommandNames = new Set(commandMatches.map(([, commandName]) => commandName))
94+
95+
const commandExistenceChecks = await Promise.all(
96+
Array.from(uniqueCommandNames).map(async (commandName) => {
97+
try {
98+
const command = await getCommand(cwd, commandName)
99+
return { commandName, command }
100+
} catch (error) {
101+
// If there's an error checking command existence, treat it as non-existent
102+
return { commandName, command: undefined }
103+
}
104+
}),
105+
)
106+
107+
// Store valid commands for later use
108+
for (const { commandName, command } of commandExistenceChecks) {
109+
if (command) {
110+
validCommands.set(commandName, command)
111+
}
112+
}
113+
114+
// Only replace text for commands that actually exist
115+
let parsedText = text
116+
for (const [match, commandName] of commandMatches) {
117+
if (validCommands.has(commandName)) {
118+
parsedText = parsedText.replace(match, `Command '${commandName}' (see below for command content)`)
119+
}
120+
}
96121

97122
// Second pass: handle regular mentions
98123
parsedText = parsedText.replace(mentionRegexGlobal, (match, mention) => {
@@ -213,20 +238,15 @@ export async function parseMentions(
213238
}
214239
}
215240

216-
// Process command mentions
217-
for (const commandName of commandMentions) {
241+
// Process valid command mentions using cached results
242+
for (const [commandName, command] of validCommands) {
218243
try {
219-
const command = await getCommand(cwd, commandName)
220-
if (command) {
221-
let commandOutput = ""
222-
if (command.description) {
223-
commandOutput += `Description: ${command.description}\n\n`
224-
}
225-
commandOutput += command.content
226-
parsedText += `\n\n<command name="${commandName}">\n${commandOutput}\n</command>`
227-
} else {
228-
parsedText += `\n\n<command name="${commandName}">\nCommand '${commandName}' not found. Available commands can be found in .roo/commands/ or ~/.roo/commands/\n</command>`
244+
let commandOutput = ""
245+
if (command.description) {
246+
commandOutput += `Description: ${command.description}\n\n`
229247
}
248+
commandOutput += command.content
249+
parsedText += `\n\n<command name="${commandName}">\n${commandOutput}\n</command>`
230250
} catch (error) {
231251
parsedText += `\n\n<command name="${commandName}">\nError loading command '${commandName}': ${error.message}\n</command>`
232252
}

0 commit comments

Comments
 (0)