mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: expose skills as slash commands with skill fallback execution (#11834)
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
ce73d05646
commit
af1e12c76d
12 changed files with 687 additions and 43 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ describe("processUserContentMentions", () => {
|
|||
false, // showRooIgnoredFiles should default to false
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
undefined,
|
||||
"code",
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -220,6 +222,8 @@ describe("processUserContentMentions", () => {
|
|||
false,
|
||||
true, // includeDiagnosticMessages
|
||||
50, // maxDiagnosticMessages
|
||||
undefined,
|
||||
"code",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -40,6 +41,8 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles = false,
|
||||
includeDiagnosticMessages = true,
|
||||
maxDiagnosticMessages = 50,
|
||||
skillsManager,
|
||||
currentMode = "code",
|
||||
}: {
|
||||
userContent: Anthropic.Messages.ContentBlockParam[]
|
||||
cwd: string
|
||||
|
|
@ -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
|
||||
|
|
@ -69,6 +74,8 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
|
|
@ -112,6 +119,8 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
|
|
@ -161,6 +170,8 @@ export async function processUserContentMentions({
|
|||
showRooIgnoredFiles,
|
||||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -61,13 +66,7 @@ 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)
|
||||
|
||||
|
|
@ -75,21 +74,7 @@ export class SkillTool extends BaseTool<"skill"> {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ describe("runSlashCommandTool", () => {
|
|||
runSlashCommand: true,
|
||||
},
|
||||
}),
|
||||
getSkillsManager: vi.fn().mockReturnValue(undefined),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
|
@ -83,6 +84,122 @@ describe("runSlashCommandTool", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("should fallback to skill content when command is missing and matching skill exists", async () => {
|
||||
const block: ToolUse<"run_slash_command"> = {
|
||||
type: "tool_use" as const,
|
||||
name: "run_slash_command" as const,
|
||||
params: {},
|
||||
partial: false,
|
||||
nativeArgs: {
|
||||
command: "skill-only",
|
||||
args: "target flow",
|
||||
},
|
||||
}
|
||||
|
||||
const 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",
|
||||
})
|
||||
|
||||
mockTask.providerRef.deref = vi.fn().mockReturnValue({
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
experiments: {
|
||||
runSlashCommand: true,
|
||||
},
|
||||
mode: "code",
|
||||
}),
|
||||
getSkillsManager: vi.fn().mockReturnValue({
|
||||
getSkillContent,
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mocked(getCommand).mockResolvedValue(undefined)
|
||||
|
||||
await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks)
|
||||
|
||||
expect(getSkillContent).toHaveBeenCalledWith("skill-only", "code")
|
||||
expect(mockCallbacks.askApproval).toHaveBeenCalledWith(
|
||||
"tool",
|
||||
JSON.stringify({
|
||||
tool: "skill",
|
||||
skill: "skill-only",
|
||||
args: "target flow",
|
||||
source: "project",
|
||||
description: "Skill-generated command",
|
||||
}),
|
||||
)
|
||||
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
|
||||
`Skill: skill-only
|
||||
Description: Skill-generated command
|
||||
Provided arguments: target flow
|
||||
Source: project
|
||||
|
||||
--- Skill Instructions ---
|
||||
|
||||
Use skill workflow`,
|
||||
)
|
||||
expect(mockTask.recordToolError).not.toHaveBeenCalledWith("run_slash_command")
|
||||
expect(getCommandNames).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should preserve command precedence over skill fallback", async () => {
|
||||
const block: ToolUse<"run_slash_command"> = {
|
||||
type: "tool_use" as const,
|
||||
name: "run_slash_command" as const,
|
||||
params: {},
|
||||
partial: false,
|
||||
nativeArgs: {
|
||||
command: "setup",
|
||||
},
|
||||
}
|
||||
|
||||
const mockCommand = {
|
||||
name: "setup",
|
||||
content: "Command content",
|
||||
source: "project" as const,
|
||||
filePath: ".roo/commands/setup.md",
|
||||
description: "Real command",
|
||||
}
|
||||
|
||||
const getSkillContent = vi.fn().mockResolvedValue({
|
||||
name: "setup",
|
||||
description: "Setup skill",
|
||||
path: "/mock/.roo/skills/setup/SKILL.md",
|
||||
source: "project" as const,
|
||||
instructions: "Skill should not run",
|
||||
})
|
||||
|
||||
mockTask.providerRef.deref = vi.fn().mockReturnValue({
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
experiments: {
|
||||
runSlashCommand: true,
|
||||
},
|
||||
mode: "code",
|
||||
}),
|
||||
getSkillsManager: vi.fn().mockReturnValue({
|
||||
getSkillContent,
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mocked(getCommand).mockResolvedValue(mockCommand)
|
||||
|
||||
await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks)
|
||||
|
||||
expect(getSkillContent).not.toHaveBeenCalled()
|
||||
expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
|
||||
`Command: /setup
|
||||
Description: Real command
|
||||
Source: project
|
||||
|
||||
--- Command Content ---
|
||||
|
||||
Command content`,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle user rejection", async () => {
|
||||
const block: ToolUse<"run_slash_command"> = {
|
||||
type: "tool_use" as const,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,22 @@ vi.mock("../../../integrations/openai-codex/rate-limits", () => ({
|
|||
fetchOpenAiCodexRateLimitInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../../../services/command/commands", () => ({
|
||||
getCommands: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@anthropic-ai/vertex-sdk", () => ({
|
||||
AnthropicVertex: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ollama", () => ({
|
||||
Ollama: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock the diagnosticsHandler module
|
||||
vi.mock("../diagnosticsHandler", () => ({
|
||||
generateErrorDiagnostics: vi.fn().mockResolvedValue({ success: true, filePath: "/tmp/diagnostics.json" }),
|
||||
|
|
@ -26,10 +42,12 @@ import type { ModelRecord } from "@roo-code/types"
|
|||
import { webviewMessageHandler } from "../webviewMessageHandler"
|
||||
import type { ClineProvider } from "../ClineProvider"
|
||||
import { getModels } from "../../../api/providers/fetchers/modelCache"
|
||||
import { getCommands } from "../../../services/command/commands"
|
||||
const { openAiCodexOAuthManager } = await import("../../../integrations/openai-codex/oauth")
|
||||
const { fetchOpenAiCodexRateLimitInfo } = await import("../../../integrations/openai-codex/rate-limits")
|
||||
|
||||
const mockGetModels = getModels as Mock<typeof getModels>
|
||||
const mockGetCommands = vi.mocked(getCommands)
|
||||
const mockGetAccessToken = vi.mocked(openAiCodexOAuthManager.getAccessToken)
|
||||
const mockGetAccountId = vi.mocked(openAiCodexOAuthManager.getAccountId)
|
||||
const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInfo)
|
||||
|
|
@ -59,6 +77,8 @@ const mockClineProvider = {
|
|||
getCurrentTask: vi.fn(),
|
||||
getTaskWithId: vi.fn(),
|
||||
createTaskWithHistoryItem: vi.fn(),
|
||||
getSkillsManager: vi.fn(),
|
||||
cwd: "/mock/workspace",
|
||||
} as unknown as ClineProvider
|
||||
|
||||
import { t } from "../../../i18n"
|
||||
|
|
@ -809,6 +829,182 @@ describe("webviewMessageHandler - mcpEnabled", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("webviewMessageHandler - requestCommands", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("includes skill slug commands and dedupes duplicate skill names while preserving first skill entry", async () => {
|
||||
mockGetCommands.mockResolvedValue([])
|
||||
|
||||
const getTaskMode = vi.fn().mockResolvedValue("code")
|
||||
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
|
||||
cwd: "/mock/workspace",
|
||||
getTaskMode,
|
||||
} as unknown as ReturnType<ClineProvider["getCurrentTask"]>)
|
||||
|
||||
const getSkillsForMode = vi.fn().mockReturnValue([
|
||||
{
|
||||
name: "skill-slug-entry",
|
||||
description: "Primary skill slug",
|
||||
path: "/mock/.roo/skills/skill-slug-entry/SKILL.md",
|
||||
source: "project",
|
||||
modeSlugs: ["code"],
|
||||
},
|
||||
{
|
||||
name: "skill-slug-entry",
|
||||
description: "Duplicate skill slug",
|
||||
path: "/mock/.roo/skills/duplicate-skill/SKILL.md",
|
||||
source: "global",
|
||||
modeSlugs: ["code"],
|
||||
},
|
||||
{
|
||||
name: "another-skill-slug",
|
||||
description: "Another skill-generated command",
|
||||
path: "/mock/.roo/skills/another-skill-slug/SKILL.md",
|
||||
source: "global",
|
||||
modeSlugs: ["code"],
|
||||
},
|
||||
])
|
||||
|
||||
vi.mocked(mockClineProvider.getSkillsManager).mockReturnValue({
|
||||
getSkillsForMode,
|
||||
} as unknown as ReturnType<ClineProvider["getSkillsManager"]>)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, { type: "requestCommands" })
|
||||
|
||||
const commandMessageCall = vi
|
||||
.mocked(mockClineProvider.postMessageToWebview)
|
||||
.mock.calls.find(([postedMessage]) => postedMessage.type === "commands")
|
||||
expect(commandMessageCall).toBeDefined()
|
||||
|
||||
const commandMessage = commandMessageCall?.[0]
|
||||
expect(commandMessage?.commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: "skill-slug-entry",
|
||||
source: "project",
|
||||
filePath: "/mock/.roo/skills/skill-slug-entry/SKILL.md",
|
||||
description: "Primary skill slug",
|
||||
},
|
||||
{
|
||||
name: "another-skill-slug",
|
||||
source: "global",
|
||||
filePath: "/mock/.roo/skills/another-skill-slug/SKILL.md",
|
||||
description: "Another skill-generated command",
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
expect(commandMessage?.commands?.filter((command) => command.name === "skill-slug-entry")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("adds skill-backed command entries without overriding existing command names", async () => {
|
||||
mockGetCommands.mockResolvedValue([
|
||||
{
|
||||
name: "deploy",
|
||||
content: "existing command",
|
||||
source: "project",
|
||||
filePath: "/mock/workspace/.roo/commands/deploy.md",
|
||||
description: "Deploy command",
|
||||
argumentHint: "staging | production",
|
||||
},
|
||||
])
|
||||
|
||||
const getTaskMode = vi.fn().mockResolvedValue("code")
|
||||
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
|
||||
cwd: "/mock/workspace",
|
||||
getTaskMode,
|
||||
} as unknown as ReturnType<ClineProvider["getCurrentTask"]>)
|
||||
|
||||
const getSkillsForMode = vi.fn().mockReturnValue([
|
||||
{
|
||||
name: "deploy",
|
||||
description: "Deploy skill",
|
||||
path: "/mock/.roo/skills/deploy/SKILL.md",
|
||||
source: "global",
|
||||
modeSlugs: ["code"],
|
||||
},
|
||||
{
|
||||
name: "skill-only",
|
||||
description: "Skill-generated command",
|
||||
path: "/mock/.roo/skills/skill-only/SKILL.md",
|
||||
source: "project",
|
||||
modeSlugs: ["code"],
|
||||
},
|
||||
])
|
||||
|
||||
vi.mocked(mockClineProvider.getSkillsManager).mockReturnValue({
|
||||
getSkillsForMode,
|
||||
} as unknown as ReturnType<ClineProvider["getSkillsManager"]>)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, { type: "requestCommands" })
|
||||
|
||||
expect(getSkillsForMode).toHaveBeenCalledWith("code")
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "commands",
|
||||
commands: expect.arrayContaining([
|
||||
{
|
||||
name: "deploy",
|
||||
source: "project",
|
||||
filePath: "/mock/workspace/.roo/commands/deploy.md",
|
||||
description: "Deploy command",
|
||||
argumentHint: "staging | production",
|
||||
},
|
||||
{
|
||||
name: "skill-only",
|
||||
source: "project",
|
||||
filePath: "/mock/.roo/skills/skill-only/SKILL.md",
|
||||
description: "Skill-generated command",
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const commandMessageCall = vi
|
||||
.mocked(mockClineProvider.postMessageToWebview)
|
||||
.mock.calls.find(([postedMessage]) => postedMessage.type === "commands")
|
||||
expect(commandMessageCall).toBeDefined()
|
||||
|
||||
const commandMessage = commandMessageCall?.[0]
|
||||
expect(commandMessage?.commands?.filter((command) => command.name === "deploy")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("preserves existing behavior when skills manager is unavailable", async () => {
|
||||
mockGetCommands.mockResolvedValue([
|
||||
{
|
||||
name: "build",
|
||||
content: "build command",
|
||||
source: "built-in",
|
||||
filePath: "<built-in:build>",
|
||||
description: "Build command",
|
||||
argumentHint: "target",
|
||||
},
|
||||
])
|
||||
|
||||
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
|
||||
cwd: "/mock/workspace",
|
||||
} as unknown as ReturnType<ClineProvider["getCurrentTask"]>)
|
||||
|
||||
vi.mocked(mockClineProvider.getSkillsManager).mockReturnValue(undefined)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, { type: "requestCommands" })
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "commands",
|
||||
commands: [
|
||||
{
|
||||
name: "build",
|
||||
source: "built-in",
|
||||
filePath: "<built-in:build>",
|
||||
description: "Build command",
|
||||
argumentHint: "target",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("webviewMessageHandler - downloadErrorDiagnostics", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type TelemetrySetting,
|
||||
type UserSettingsConfig,
|
||||
type ModelRecord,
|
||||
type Command as SlashCommand,
|
||||
type WebviewMessage,
|
||||
type EditQueuedMessagePayload,
|
||||
TelemetryEventName,
|
||||
|
|
@ -102,6 +103,72 @@ export const webviewMessageHandler = async (
|
|||
return provider.getCurrentTask()?.cwd || provider.cwd
|
||||
}
|
||||
|
||||
const getCurrentMode = async (): Promise<string> => {
|
||||
const currentTask = provider.getCurrentTask()
|
||||
|
||||
if (currentTask) {
|
||||
try {
|
||||
return await currentTask.getTaskMode()
|
||||
} catch (error) {
|
||||
provider.log(
|
||||
`Error resolving current task mode for command discovery: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await provider.getState()
|
||||
if (typeof state.mode === "string" && state.mode.length > 0) {
|
||||
return state.mode
|
||||
}
|
||||
} catch (error) {
|
||||
provider.log(
|
||||
`Error resolving global mode for command discovery: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return defaultModeSlug
|
||||
}
|
||||
|
||||
const getDiscoveredCommands = async (): Promise<SlashCommand[]> => {
|
||||
const { getCommands } = await import("../../services/command/commands")
|
||||
const commands = await getCommands(getCurrentCwd())
|
||||
|
||||
const commandList: SlashCommand[] = commands.map((command) => ({
|
||||
name: command.name,
|
||||
source: command.source,
|
||||
filePath: command.filePath,
|
||||
description: command.description,
|
||||
argumentHint: command.argumentHint,
|
||||
}))
|
||||
|
||||
const existingCommandNames = new Set(commandList.map((command) => command.name))
|
||||
const skillsManager = provider.getSkillsManager()
|
||||
|
||||
if (!skillsManager) {
|
||||
return commandList
|
||||
}
|
||||
|
||||
const currentMode = await getCurrentMode()
|
||||
const availableSkills = skillsManager.getSkillsForMode(currentMode)
|
||||
|
||||
for (const skill of availableSkills) {
|
||||
if (existingCommandNames.has(skill.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
existingCommandNames.add(skill.name)
|
||||
commandList.push({
|
||||
name: skill.name,
|
||||
source: skill.source,
|
||||
filePath: skill.path,
|
||||
description: skill.description,
|
||||
})
|
||||
}
|
||||
|
||||
return commandList
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves image file mentions in incoming messages.
|
||||
* Matches read_file behavior: respects size limits and model capabilities.
|
||||
|
|
@ -2931,17 +2998,7 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
case "requestCommands": {
|
||||
try {
|
||||
const { getCommands } = await import("../../services/command/commands")
|
||||
const commands = await getCommands(getCurrentCwd())
|
||||
|
||||
const commandList = commands.map((command) => ({
|
||||
name: command.name,
|
||||
source: command.source,
|
||||
filePath: command.filePath,
|
||||
description: command.description,
|
||||
argumentHint: command.argumentHint,
|
||||
}))
|
||||
|
||||
const commandList = await getDiscoveredCommands()
|
||||
await provider.postMessageToWebview({ type: "commands", commands: commandList })
|
||||
} catch (error) {
|
||||
provider.log(`Error fetching commands: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
|
|
|
|||
106
src/services/skills/__tests__/skillInvocation.spec.ts
Normal file
106
src/services/skills/__tests__/skillInvocation.spec.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { resolveSkillContentForMode, buildSkillApprovalMessage, buildSkillResult } from "../skillInvocation"
|
||||
import type { SkillLookup } from "../skillInvocation"
|
||||
import type { SkillContent } from "../../../shared/skills"
|
||||
|
||||
describe("skillInvocation", () => {
|
||||
const mockSkillContent: SkillContent = {
|
||||
name: "test-skill",
|
||||
description: "A test skill",
|
||||
path: "/mock/.roo/skills/test-skill/SKILL.md",
|
||||
source: "project",
|
||||
instructions: "Do the thing",
|
||||
}
|
||||
|
||||
describe("resolveSkillContentForMode", () => {
|
||||
it("returns null when skillsManager is undefined", async () => {
|
||||
const result = await resolveSkillContentForMode(undefined, "test-skill", "code")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("delegates to skillsManager.getSkillContent with correct arguments", async () => {
|
||||
const skillsManager: SkillLookup = {
|
||||
getSkillContent: vi.fn().mockResolvedValue(mockSkillContent),
|
||||
}
|
||||
|
||||
const result = await resolveSkillContentForMode(skillsManager, "test-skill", "architect")
|
||||
expect(skillsManager.getSkillContent).toHaveBeenCalledWith("test-skill", "architect")
|
||||
expect(result).toBe(mockSkillContent)
|
||||
})
|
||||
|
||||
it("returns null when skillsManager returns null", async () => {
|
||||
const skillsManager: SkillLookup = {
|
||||
getSkillContent: vi.fn().mockResolvedValue(null),
|
||||
}
|
||||
|
||||
const result = await resolveSkillContentForMode(skillsManager, "nonexistent", "code")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildSkillApprovalMessage", () => {
|
||||
it("produces valid JSON with skill, args, source, and description", () => {
|
||||
const message = buildSkillApprovalMessage("deploy", "staging", {
|
||||
source: "project",
|
||||
description: "Deploy to env",
|
||||
})
|
||||
|
||||
expect(JSON.parse(message)).toEqual({
|
||||
tool: "skill",
|
||||
skill: "deploy",
|
||||
args: "staging",
|
||||
source: "project",
|
||||
description: "Deploy to env",
|
||||
})
|
||||
})
|
||||
|
||||
it("includes undefined args when no args provided", () => {
|
||||
const message = buildSkillApprovalMessage("build", undefined, {
|
||||
source: "global",
|
||||
description: "Build project",
|
||||
})
|
||||
|
||||
const parsed = JSON.parse(message)
|
||||
expect(parsed.args).toBeUndefined()
|
||||
expect(parsed.skill).toBe("build")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildSkillResult", () => {
|
||||
it("builds full result with description, args, source, and instructions", () => {
|
||||
const result = buildSkillResult("deploy", "production", mockSkillContent)
|
||||
|
||||
expect(result).toBe(
|
||||
`Skill: deploy\nDescription: A test skill\nProvided arguments: production\nSource: project\n\n--- Skill Instructions ---\n\nDo the thing`,
|
||||
)
|
||||
})
|
||||
|
||||
it("omits description line when description is empty", () => {
|
||||
const skillContent = { ...mockSkillContent, description: "" }
|
||||
const result = buildSkillResult("deploy", "staging", skillContent)
|
||||
|
||||
expect(result).not.toContain("Description:")
|
||||
expect(result).toContain("Skill: deploy")
|
||||
expect(result).toContain("Provided arguments: staging")
|
||||
})
|
||||
|
||||
it("omits arguments line when args is undefined", () => {
|
||||
const result = buildSkillResult("deploy", undefined, mockSkillContent)
|
||||
|
||||
expect(result).not.toContain("Provided arguments:")
|
||||
expect(result).toContain("Skill: deploy")
|
||||
expect(result).toContain("Description: A test skill")
|
||||
})
|
||||
|
||||
it("includes source and instructions in all cases", () => {
|
||||
const result = buildSkillResult("minimal", undefined, {
|
||||
source: "global",
|
||||
description: "",
|
||||
instructions: "Step 1: do stuff",
|
||||
})
|
||||
|
||||
expect(result).toContain("Source: global")
|
||||
expect(result).toContain("--- Skill Instructions ---")
|
||||
expect(result).toContain("Step 1: do stuff")
|
||||
})
|
||||
})
|
||||
})
|
||||
54
src/services/skills/skillInvocation.ts
Normal file
54
src/services/skills/skillInvocation.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { SkillContent } from "../../shared/skills"
|
||||
|
||||
export interface SkillLookup {
|
||||
getSkillContent(name: string, currentMode?: string): Promise<SkillContent | null>
|
||||
}
|
||||
|
||||
export async function resolveSkillContentForMode(
|
||||
skillsManager: SkillLookup | undefined,
|
||||
skillName: string,
|
||||
currentMode: string,
|
||||
): Promise<SkillContent | null> {
|
||||
if (!skillsManager) {
|
||||
return null
|
||||
}
|
||||
|
||||
return skillsManager.getSkillContent(skillName, currentMode)
|
||||
}
|
||||
|
||||
type SkillContentForFormatting = Pick<SkillContent, "source" | "description" | "instructions">
|
||||
|
||||
export function buildSkillApprovalMessage(
|
||||
skillName: string,
|
||||
args: string | undefined,
|
||||
skillContent: Pick<SkillContent, "source" | "description">,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
tool: "skill",
|
||||
skill: skillName,
|
||||
args,
|
||||
source: skillContent.source,
|
||||
description: skillContent.description,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildSkillResult(
|
||||
skillName: string,
|
||||
args: string | undefined,
|
||||
skillContent: SkillContentForFormatting,
|
||||
): string {
|
||||
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}`
|
||||
|
||||
return result
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue