Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/__tests__/command-mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,76 @@ describe("Command Mentions", () => {
expect(result.text).not.toContain("Command 'nonexistent' not found")
})

it("should load skill content when command is missing and mode skill exists", async () => {
mockGetCommand.mockResolvedValue(undefined)

const skillsManager = {
getSkillContent: vi.fn().mockResolvedValue({
name: "skill-only",
description: "Skill-generated command",
path: "/mock/.roo/skills/skill-only/SKILL.md",
source: "project" as const,
instructions: "Use skill workflow",
}),
}

const result = await parseMentions(
"/skill-only run",
"/test/cwd",
undefined,
undefined,
false,
true,
50,
skillsManager,
"code",
)

expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "skill-only")
expect(skillsManager.getSkillContent).toHaveBeenCalledWith("skill-only", "code")
expect(result.text).toContain("Command 'skill-only' (see below for command content)")
expect(result.slashCommandHelp).toContain("Skill: skill-only")
expect(result.slashCommandHelp).toContain("Description: Skill-generated command")
expect(result.slashCommandHelp).toContain("Source: project")
expect(result.slashCommandHelp).toContain("--- Skill Instructions ---")
expect(result.slashCommandHelp).toContain("Use skill workflow")
})

it("should preserve command precedence over skill fallback", async () => {
mockGetCommand.mockResolvedValue({
name: "setup",
content: "# Command wins",
source: "project",
filePath: "/project/.roo/commands/setup.md",
})

const skillsManager = {
getSkillContent: vi.fn().mockResolvedValue({
name: "setup",
description: "Setup skill",
path: "/mock/.roo/skills/setup/SKILL.md",
source: "project" as const,
instructions: "Skill should not be used",
}),
}

const result = await parseMentions(
"/setup now",
"/test/cwd",
undefined,
undefined,
false,
true,
50,
skillsManager,
"code",
)

expect(skillsManager.getSkillContent).not.toHaveBeenCalled()
expect(result.slashCommandHelp).toContain('<command name="setup">')
expect(result.slashCommandHelp).not.toContain("Skill: setup")
})

it("should handle command loading errors during existence check", async () => {
mockGetCommand.mockReset()
mockGetCommand.mockRejectedValue(new Error("Failed to load command"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ describe("processUserContentMentions", () => {
false, // showRooIgnoredFiles should default to false
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
undefined,
"code",
)
})

Expand All @@ -220,6 +222,8 @@ describe("processUserContentMentions", () => {
false,
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
undefined,
"code",
)
})
})
Expand Down
27 changes: 23 additions & 4 deletions src/core/mentions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { FileContextTracker } from "../context-tracking/FileContextTracker"

import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getCommand, type Command } from "../../services/command/commands"
import { buildSkillResult, resolveSkillContentForMode, type SkillLookup } from "../../services/skills/skillInvocation"
import type { SkillContent } from "../../shared/skills"

export async function openMention(cwd: string, mention?: string): Promise<void> {
if (!mention) {
Expand Down Expand Up @@ -102,9 +104,12 @@ export async function parseMentions(
showRooIgnoredFiles: boolean = false,
includeDiagnosticMessages: boolean = true,
maxDiagnosticMessages: number = 50,
skillsManager?: SkillLookup,
currentMode: string = "code",
): Promise<ParseMentionsResult> {
const mentions: Set<string> = new Set()
const validCommands: Map<string, Command> = new Map()
const validSkills: Map<string, SkillContent> = new Map()
const contentBlocks: MentionContentBlock[] = []
let commandMode: string | undefined // Track mode from the first slash command that has one

Expand All @@ -116,29 +121,39 @@ export async function parseMentions(
Array.from(uniqueCommandNames).map(async (commandName) => {
try {
const command = await getCommand(cwd, commandName)
return { commandName, command }
if (command) {
return { commandName, command, skillContent: null }
}

const skillContent = await resolveSkillContentForMode(skillsManager, commandName, currentMode)
return { commandName, command: undefined, skillContent }
} catch (error) {
// If there's an error checking command existence, treat it as non-existent
return { commandName, command: undefined }
return { commandName, command: undefined, skillContent: null }
}
}),
)

// Store valid commands for later use and capture the first mode found
for (const { commandName, command } of commandExistenceChecks) {
for (const { commandName, command, skillContent } of commandExistenceChecks) {
if (command) {
validCommands.set(commandName, command)
// Capture the mode from the first command that has one
if (!commandMode && command.mode) {
commandMode = command.mode
}
continue
}

if (skillContent) {
validSkills.set(commandName, skillContent)
}
}

// Only replace text for commands that actually exist (keep "see below" for commands)
let parsedText = text
for (const [match, commandName] of commandMatches) {
if (validCommands.has(commandName)) {
if (validCommands.has(commandName) || validSkills.has(commandName)) {
parsedText = parsedText.replace(match, `Command '${commandName}' (see below for command content)`)
}
}
Expand Down Expand Up @@ -231,6 +246,10 @@ export async function parseMentions(
}
}

for (const [skillName, skillContent] of validSkills) {
slashCommandHelp += `\n\n${buildSkillResult(skillName, undefined, skillContent)}`
}

return {
text: parsedText,
contentBlocks,
Expand Down
11 changes: 11 additions & 0 deletions src/core/mentions/processUserContentMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Anthropic from "@anthropic-ai/sdk"

import { parseMentions, ParseMentionsResult, MentionContentBlock } from "./index"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import type { SkillLookup } from "../../services/skills/skillInvocation"

// Internal aliases for the Anthropic content block subtypes used during processing.
type TextPart = Anthropic.Messages.TextBlockParam
Expand Down Expand Up @@ -40,6 +41,8 @@ export async function processUserContentMentions({
showRooIgnoredFiles = false,
includeDiagnosticMessages = true,
maxDiagnosticMessages = 50,
skillsManager,
currentMode = "code",
}: {
userContent: Anthropic.Messages.ContentBlockParam[]
cwd: string
Expand All @@ -48,6 +51,8 @@ export async function processUserContentMentions({
showRooIgnoredFiles?: boolean
includeDiagnosticMessages?: boolean
maxDiagnosticMessages?: number
skillsManager?: SkillLookup
currentMode?: string
}): Promise<ProcessUserContentMentionsResult> {
// Track the first mode found from slash commands
let commandMode: string | undefined
Expand All @@ -69,6 +74,8 @@ export async function processUserContentMentions({
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
skillsManager,
currentMode,
)
// Capture the first mode found
if (!commandMode && result.mode) {
Expand Down Expand Up @@ -112,6 +119,8 @@ export async function processUserContentMentions({
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
skillsManager,
currentMode,
)
// Capture the first mode found
if (!commandMode && result.mode) {
Expand Down Expand Up @@ -161,6 +170,8 @@ export async function processUserContentMentions({
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
skillsManager,
currentMode,
)
// Capture the first mode found
if (!commandMode && result.mode) {
Expand Down
14 changes: 9 additions & 5 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2592,11 +2592,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}),
)

const {
showRooIgnoredFiles = false,
includeDiagnosticMessages = true,
maxDiagnosticMessages = 50,
} = (await this.providerRef.deref()?.getState()) ?? {}
const provider = this.providerRef.deref()
const state = provider ? await provider.getState() : undefined

const showRooIgnoredFiles = state?.showRooIgnoredFiles ?? false
const includeDiagnosticMessages = state?.includeDiagnosticMessages ?? true
const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50
const currentMode = state?.mode ?? defaultModeSlug

const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({
userContent: currentUserContent,
Expand All @@ -2606,6 +2608,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
skillsManager: provider?.getSkillsManager(),
currentMode,
})

// Switch mode if specified in a slash command's frontmatter
Expand Down
21 changes: 21 additions & 0 deletions src/core/tools/RunSlashCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { BaseTool, ToolCallbacks } from "./BaseTool"
import type { ToolUse } from "../../shared/tools"
import { getModeBySlug } from "../../shared/modes"
import {
buildSkillApprovalMessage,
buildSkillResult,
resolveSkillContentForMode,
} from "../../services/skills/skillInvocation"

interface RunSlashCommandParams {
command: string
Expand Down Expand Up @@ -50,6 +55,22 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> {
const command = await getCommand(task.cwd, commandName)

if (!command) {
const currentMode = state?.mode ?? "code"
const skillsManager = provider?.getSkillsManager()
const skillContent = await resolveSkillContentForMode(skillsManager, commandName, currentMode)

if (skillContent) {
const skillMessage = buildSkillApprovalMessage(commandName, args, skillContent)
const didApprove = await askApproval("tool", skillMessage)

if (!didApprove) {
return
}

pushToolResult(buildSkillResult(commandName, args, skillContent))
return
}

// Get available commands for error message
const availableCommands = await getCommandNames(task.cwd)
task.recordToolError("run_slash_command")
Expand Down
31 changes: 8 additions & 23 deletions src/core/tools/SkillTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { BaseTool, ToolCallbacks } from "./BaseTool"
import type { ToolUse } from "../../shared/tools"
import {
buildSkillApprovalMessage,
buildSkillResult,
resolveSkillContentForMode,
} from "../../services/skills/skillInvocation"

interface SkillParams {
skill: string
Expand Down Expand Up @@ -43,7 +48,7 @@ export class SkillTool extends BaseTool<"skill"> {
const currentMode = state?.mode ?? "code"

// Fetch skill content
const skillContent = await skillsManager.getSkillContent(skillName, currentMode)
const skillContent = await resolveSkillContentForMode(skillsManager, skillName, currentMode)

if (!skillContent) {
// Get available skills for error message
Expand All @@ -61,35 +66,15 @@ export class SkillTool extends BaseTool<"skill"> {
}

// Build approval message
const toolMessage = JSON.stringify({
tool: "skill",
skill: skillName,
args: args,
source: skillContent.source,
description: skillContent.description,
})
const toolMessage = buildSkillApprovalMessage(skillName, args, skillContent)

const didApprove = await askApproval("tool", toolMessage)

if (!didApprove) {
return
}

// Build the result message
let result = `Skill: ${skillName}`

if (skillContent.description) {
result += `\nDescription: ${skillContent.description}`
}

if (args) {
result += `\nProvided arguments: ${args}`
}

result += `\nSource: ${skillContent.source}`
result += `\n\n--- Skill Instructions ---\n\n${skillContent.instructions}`

pushToolResult(result)
pushToolResult(buildSkillResult(skillName, args, skillContent))
} catch (error) {
await handleError("executing skill", error as Error)
}
Expand Down
Loading
Loading