diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts index c421a047a16..4f1f7613074 100644 --- a/src/__tests__/command-mentions.spec.ts +++ b/src/__tests__/command-mentions.spec.ts @@ -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('') + 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")) diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 0541c7d9414..c7e25f438a2 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -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", ) }) }) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index d71317d6495..1bfb90d23f1 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -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 { 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 { const mentions: Set = new Set() const validCommands: Map = new Map() + const validSkills: Map = 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, diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 524cb010467..ab517839367 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -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 { // 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) { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4f8a5e49fdc..005bb0f292b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2592,11 +2592,13 @@ export class Task extends EventEmitter 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 implements TaskLike { showRooIgnoredFiles, includeDiagnosticMessages, maxDiagnosticMessages, + skillsManager: provider?.getSkillsManager(), + currentMode, }) // Switch mode if specified in a slash command's frontmatter diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index 0bcf970226f..c6fd48665df 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -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") diff --git a/src/core/tools/SkillTool.ts b/src/core/tools/SkillTool.ts index 213cfd91ee8..fa696e5b737 100644 --- a/src/core/tools/SkillTool.ts +++ b/src/core/tools/SkillTool.ts @@ -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) } diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index 9aa7970b99b..e3d135b45f9 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -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, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 1cd8285993c..2dcaaf3db51 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -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 +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) + + 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) + + 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) + + 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) + + 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: "", + description: "Build command", + argumentHint: "target", + }, + ]) + + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ + cwd: "/mock/workspace", + } as unknown as ReturnType) + + vi.mocked(mockClineProvider.getSkillsManager).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "requestCommands" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "commands", + commands: [ + { + name: "build", + source: "built-in", + filePath: "", + description: "Build command", + argumentHint: "target", + }, + ], + }) + }) +}) + describe("webviewMessageHandler - downloadErrorDiagnostics", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 89f29ae2e1f..f1be1316d20 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 => { + 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 => { + 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)}`) diff --git a/src/services/skills/__tests__/skillInvocation.spec.ts b/src/services/skills/__tests__/skillInvocation.spec.ts new file mode 100644 index 00000000000..c3a5c6ad617 --- /dev/null +++ b/src/services/skills/__tests__/skillInvocation.spec.ts @@ -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") + }) + }) +}) diff --git a/src/services/skills/skillInvocation.ts b/src/services/skills/skillInvocation.ts new file mode 100644 index 00000000000..839ec9764af --- /dev/null +++ b/src/services/skills/skillInvocation.ts @@ -0,0 +1,54 @@ +import type { SkillContent } from "../../shared/skills" + +export interface SkillLookup { + getSkillContent(name: string, currentMode?: string): Promise +} + +export async function resolveSkillContentForMode( + skillsManager: SkillLookup | undefined, + skillName: string, + currentMode: string, +): Promise { + if (!skillsManager) { + return null + } + + return skillsManager.getSkillContent(skillName, currentMode) +} + +type SkillContentForFormatting = Pick + +export function buildSkillApprovalMessage( + skillName: string, + args: string | undefined, + skillContent: Pick, +): 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 +}