feat: expose skills as slash commands with skill fallback execution (#11834)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Hannes Rudolph 2026-03-02 15:10:32 -07:00 committed by GitHub
parent ce73d05646
commit af1e12c76d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 687 additions and 43 deletions

View file

@ -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"))

View file

@ -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",
)
})
})

View file

@ -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,

View file

@ -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) {

View file

@ -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

View file

@ -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")

View file

@ -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)
}

View file

@ -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,

View file

@ -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()

View file

@ -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)}`)

View 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")
})
})
})

View 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
}